forked from yvv/decision
v3 : modules d'ordre et de titre — moteur, données, serveur (L11)
L'ordre des articles de chaque section (cran 2) et le titre du document (cran 4) deviennent des modules : des unités à part, dont les versions sont des propositions votées comme les articles (dépliées, panier, chaîne). - moteur : ld-engine/src/order.rs (qui est dans l'ordre, ordre proposé, complété à la fin quand un article entre ou sort, places redistribuées, nouvel article en fin de section) ; pending : un ordre de moins de deux articles n'est jamais à voter ; - données : migration 0007 (units.kind, module_amendments) ; le seed déclare ses modules ; module_amend.rs les ajoute une fois, gardé et journalisé, aux documents nés sans eux, sans toucher articles ni votes ; - serveur : modules.rs, order.rs, doc_title.rs (portes pour proposer, entrée en vigueur : les articles changent de place, le document change de titre, en et es passent « à revoir ») ; publication des modules ; - reste mineur : la médiane compte les statuts open, in_force, applied, et eux seuls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,8 +15,9 @@ use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::ballots;
|
||||
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow};
|
||||
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::modules;
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
@@ -162,6 +163,9 @@ pub async fn assist(
|
||||
pub struct CreateBody {
|
||||
kind: Option<String>,
|
||||
unit_id: Option<String>,
|
||||
/// The section a new article goes in, at its end (ADR 0009).
|
||||
section: Option<String>,
|
||||
/// Or an article of that section (an older client).
|
||||
after_unit_id: Option<String>,
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
@@ -170,27 +174,16 @@ pub struct CreateBody {
|
||||
submit: bool,
|
||||
}
|
||||
|
||||
/// Position right after `after` (or at the end), between it and the next unit.
|
||||
fn position_after(units: &[UnitRow], after: Option<&str>) -> f64 {
|
||||
let mut positions: Vec<f64> = units.iter().map(|u| u.position).collect();
|
||||
positions.sort_by(f64::total_cmp);
|
||||
let Some(anchor) = after.and_then(|a| units.iter().find(|u| u.id == a)).map(|u| u.position) else {
|
||||
return positions.last().copied().unwrap_or(0.0) + 1.0;
|
||||
};
|
||||
match positions.iter().find(|p| **p > anchor) {
|
||||
Some(next) => (anchor + next) / 2.0,
|
||||
None => anchor + 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppResult<String> {
|
||||
/// A new article goes at the end of its section (ADR 0009): the order of the
|
||||
/// section is a module of its own, voted apart.
|
||||
async fn new_unit(state: &AppState, st: &DocState, section: Option<&str>) -> AppResult<String> {
|
||||
let id = util::id();
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND code LIKE 'N%'")
|
||||
.bind(&st.doc.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
// A new article belongs to the section of the one it follows.
|
||||
let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone());
|
||||
let places: Vec<(Option<&str>, f64)> = st.articles().map(|u| (u.section.as_deref(), u.position)).collect();
|
||||
let position = ld_engine::order::place_at_end(&places, section);
|
||||
sqlx::query(
|
||||
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
|
||||
@@ -199,7 +192,7 @@ async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppRe
|
||||
.bind(&st.doc.id)
|
||||
.bind(format!("N{}", n + 1))
|
||||
.bind(section)
|
||||
.bind(position_after(&st.units, after))
|
||||
.bind(position)
|
||||
.bind(InertiaLevel::DEFAULT.as_db())
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
@@ -260,12 +253,22 @@ pub async fn create(
|
||||
let unit_id = match b.unit_id.as_deref() {
|
||||
Some(u) => {
|
||||
let unit = st.units.iter().find(|x| x.id == u).ok_or(AppError::NotFound)?;
|
||||
modules::require_article(&st, &unit.id)?;
|
||||
if kind == "remove" && unit.in_force_id.is_none() {
|
||||
return Err(AppError::Invalid("nothing_to_remove"));
|
||||
}
|
||||
unit.id.clone()
|
||||
}
|
||||
None if kind == "text" => new_unit(&state, &st, b.after_unit_id.as_deref()).await?,
|
||||
None if kind == "text" => {
|
||||
// A new article belongs to the section asked for, or to that of the article named.
|
||||
let after = b.after_unit_id.as_deref().and_then(|a| st.articles().find(|u| u.id == a));
|
||||
let section = match b.section.as_deref() {
|
||||
Some(s) if st.sections().iter().any(|x| x == s) => Some(s),
|
||||
Some(_) => return Err(AppError::Invalid("section")),
|
||||
None => after.and_then(|u| u.section.as_deref()),
|
||||
};
|
||||
new_unit(&state, &st, section).await?
|
||||
}
|
||||
None => return Err(AppError::Invalid("unit")),
|
||||
};
|
||||
let id = insert_proposal(
|
||||
@@ -297,6 +300,7 @@ pub async fn edit(
|
||||
Json(b): Json<EditBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
modules::require_text(&p)?;
|
||||
check_text(&b.title, &b.body, p.kind == "text")?;
|
||||
let (title, body) = (b.title.trim(), b.body.trim());
|
||||
let is_author = p.author_id.as_deref() == Some(user.id.as_str());
|
||||
@@ -477,7 +481,8 @@ pub struct SplitBody {
|
||||
}
|
||||
|
||||
/// One draft becomes several: the first part stays, each other part becomes a
|
||||
/// draft on a new unit placed right after. Drafts only — nobody voted yet.
|
||||
/// draft on a new unit at the end of its section, in turn. Drafts only — nobody
|
||||
/// voted yet.
|
||||
pub async fn split(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
@@ -505,10 +510,10 @@ pub async fn split(
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
let mut created = vec![id.clone()];
|
||||
let mut after = p.unit_id.clone();
|
||||
let section = st.articles().find(|u| u.id == p.unit_id).and_then(|u| u.section.clone());
|
||||
for (n, part) in parts.iter().enumerate().skip(1) {
|
||||
let fresh = docs::load_state(&state, &st.doc.id).await?;
|
||||
let unit = new_unit(&state, &fresh, Some(&after)).await?;
|
||||
let unit = new_unit(&state, &fresh, section.as_deref()).await?;
|
||||
let title = format!("{} ({})", p.title, n + 1);
|
||||
let title: String = title.chars().take(MAX_TITLE).collect();
|
||||
created.push(
|
||||
@@ -526,7 +531,6 @@ pub async fn split(
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
after = unit;
|
||||
}
|
||||
Ok(Json(json!({ "drafts": created })))
|
||||
}
|
||||
@@ -835,6 +839,7 @@ pub async fn translate(
|
||||
Json(b): Json<EditBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
modules::require_text(&p)?;
|
||||
if !util::is_lang(&lang) || lang == st.doc.source_lang {
|
||||
return Err(AppError::Invalid("lang"));
|
||||
}
|
||||
@@ -860,21 +865,3 @@ pub async fn translate(
|
||||
}
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unit(id: &str, position: f64) -> UnitRow {
|
||||
UnitRow { id: id.into(), code: id.into(), section: None, position, inertia: "3".into(), in_force_id: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_units_slot_between_neighbours() {
|
||||
let units = [unit("a", 1.0), unit("b", 2.0), unit("c", 4.0)];
|
||||
assert_eq!(position_after(&units, Some("a")), 1.5);
|
||||
assert_eq!(position_after(&units, Some("c")), 5.0);
|
||||
assert_eq!(position_after(&units, None), 5.0);
|
||||
assert_eq!(position_after(&[], None), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user