Files
decision/crates/ld-server/src/order.rs
T
YvvandClaude Opus 5.5 093d1ca471 Lot T4 : les tests d'avant suivent le seed de T4 sans rien perdre de ce qu'ils prouvent
Les tests qui lisaient F, N ou O rejouent leurs transitions sur le seed de M8
(tests/fixtures/seed-m8) : seed_quorum_texts, seed_rules, seed_proposals, api,
quorum ; ils disent en plus que ces amendements sont dépassés par le retrait
(superseded_by). Les comptes suivent le document de T4 : 27 articles, quatre
Propositions seed (I1, E1, E12, E2), 33 modules à voter (27 articles, le titre,
l'ordre de 5 sections). modules.rs : l'ordre sous vote se rejoue sur
« conclusion » (K1, K2) ; « un ordre d'un seul article n'a rien à décider » sur
une base née du seed de M8 (O1). seed_amend : tests unitaires de
Version::SeedProposal et de la ligne seed_proposal (put_forward). order.rs ne lit
que les colonnes d'avant 0012 : sections_now ne sert qu'à la naissance des
modules, et article_amend n'en garde que les articles vivants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:21:44 +01:00

169 lines
6.8 KiB
Rust

//! The order of the articles of a section, a module (ADR 0009) — the rules are
//! the engine's (`ld_engine::order`); here, the database and the door to
//! propose one.
//!
//! - The version in force is the order the section reads in now: its articles
//! one can see, by position. The text the engine compares for it is that live
//! order, so it always reads what the reader shows.
//! - A proposal is another order of the same articles, each once. While it is
//! under vote, articles may enter the section or leave it: it is completed at
//! the end (`ld_engine::order::fit`), and its votes stay.
//! - Entering into force gives the section's places out again in the new order;
//! the articles keep their texts, their votes, their sections.
use crate::AppState;
use crate::auth::User;
use crate::docs::{self, DocState, ProposalRow, UnitRow};
use crate::error::{AppError, AppResult};
use crate::modules::{self, ModuleKind, NewVersion};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::order::{self, Article, OrderError};
use serde::Deserialize;
use serde_json::{Value, json};
use sqlx::SqliteConnection;
/// More articles than any section will hold: a longer list is refused unread.
const MAX_ARTICLES: usize = 500;
/// Every section of a document, in the order of the document, with the articles
/// of its order now (read inside a transaction: the seed writes them in it).
pub async fn sections_now(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<Vec<(String, Vec<String>)>> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
section: Option<String>,
position: f64,
visible: bool,
}
// Seen: a text in force, or an open version (`DocState::members` says the same). It reads
// only the columns of before 0012: a document born now, or given its modules by an
// amendment, holds no retired unit (ADR 0014); `article_amend` keeps the live ones.
let rows = sqlx::query_as::<_, Row>(
"SELECT u.id, u.section, u.position,
(u.in_force_id IS NOT NULL OR EXISTS (SELECT 1 FROM proposals p
WHERE p.unit_id = u.id AND p.status = 'open' AND p.kind IN ('text', 'remove'))) AS visible
FROM units u WHERE u.doc_id = ? AND u.kind = 'article' ORDER BY u.position, u.id",
)
.bind(doc_id)
.fetch_all(&mut *conn)
.await?;
let articles: Vec<Article<'_>> = rows
.iter()
.map(|r| Article { id: &r.id, section: r.section.as_deref(), position: r.position, visible: r.visible })
.collect();
let mut sections: Vec<String> = Vec::new();
for r in &rows {
if let Some(s) = &r.section
&& !sections.contains(s)
{
sections.push(s.clone());
}
}
Ok(sections
.into_iter()
.map(|s| {
let members = order::members(&articles, &s);
(s, members)
})
.collect())
}
/// The order an order version gives its section now, as the screen tells it.
pub fn version_json(st: &DocState, unit: &UnitRow, p: &ProposalRow) -> Value {
let members = st.members(unit.section.as_deref().unwrap_or_default());
if p.status == "in_force" {
return json!({ "ids": members, "added": [], "dropped": [], "same": true });
}
match order::decode(&p.body) {
Some(proposed) => {
let f = order::fit(&proposed, &members);
let same = f.order == members;
json!({ "ids": f.order, "added": f.added, "dropped": f.dropped, "same": same })
}
None => json!({ "ids": [], "added": [], "dropped": [], "same": false, "unreadable": true }),
}
}
/// The order enters into force: its articles take the section's places in its order.
pub async fn enter(conn: &mut SqliteConnection, st: &DocState, unit: &UnitRow, p: &ProposalRow) -> AppResult<()> {
let members = st.members(unit.section.as_deref().unwrap_or_default());
// An unreadable order is blocked: it never comes this far.
let Some(proposed) = order::decode(&p.body) else { return Ok(()) };
let fitted = order::fit(&proposed, &members);
let current: Vec<(String, f64)> = members
.iter()
.filter_map(|id| st.units.iter().find(|u| &u.id == id).map(|u| (id.clone(), u.position)))
.collect();
for (id, position) in order::positions(&fitted.order, &current) {
sqlx::query("UPDATE units SET position = ? WHERE id = ? AND doc_id = ? AND kind = 'article'")
.bind(position)
.bind(&id)
.bind(&st.doc.id)
.execute(&mut *conn)
.await?;
}
tracing::info!(doc = %st.doc.id, unit = %unit.code, added = fitted.added.len(), dropped = fitted.dropped.len(), "order of a section in force");
Ok(())
}
#[derive(Deserialize)]
pub struct OrderBody {
/// Every article of the section (unit ids), each once, in the order proposed.
order: Vec<String>,
}
/// Propose another order for a section. An open proposal that already reads in
/// this order is given back rather than doubled.
pub async fn propose(
State(state): State<AppState>,
User(user): User,
Path(unit_id): Path<String>,
Json(b): Json<OrderBody>,
) -> AppResult<Json<Value>> {
let doc_id: String = sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
.bind(&unit_id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
if st.module_of(&unit.id) != Some(ModuleKind::Order) {
return Err(AppError::Invalid("not_an_order"));
}
if b.order.len() > MAX_ARTICLES {
return Err(AppError::Invalid("order"));
}
let members = st.members(unit.section.as_deref().unwrap_or_default());
order::check(&b.order, &members).map_err(|e| match e {
OrderError::NotTheSection => AppError::Invalid("order"),
OrderError::Unchanged => AppError::Invalid("same_order"),
})?;
if let Some(existing) = st.proposals.iter().find(|p| {
p.unit_id == unit.id
&& p.status == "open"
&& p.kind == ModuleKind::Order.as_str()
&& order::decode(&p.body).is_some_and(|o| order::fit(&o, &members).order == b.order)
}) {
return Ok(Json(json!({ "id": existing.id, "existing": true })));
}
let now = util::now();
let mut conn = state.db.acquire().await?;
let id = modules::insert_version(
&mut conn,
NewVersion {
unit_id: &unit.id,
kind: ModuleKind::Order,
title: &unit.code,
body: &order::canonical(&b.order),
author: Some(&user.id),
in_force: false,
now: &now,
},
)
.await?;
Ok(Json(json!({ "id": id, "existing": false })))
}