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:
Yvv
2026-10-31 02:08:44 +01:00
co-authored by Claude Opus 5.5
parent 1a35d0f521
commit 3831ddb1bf
21 changed files with 2249 additions and 115 deletions
+1
View File
@@ -13,6 +13,7 @@ pub mod compose;
pub mod edit;
pub mod inertia;
pub mod names;
pub mod order;
pub mod pending;
pub mod threshold;
+329
View File
@@ -0,0 +1,329 @@
//! The order of the articles of a section — a module of its own.
//!
//! Yvv, 2026-10-30: « En fin de section, il y a un "module" qui permet de
//! changer l'ordre des "articles". Lui-même à (2), avec une simple liste
//! clipable. » The order of a section is voted like an article: its version in
//! force is the order the section reads in now; a proposal is another order.
//!
//! The rules, purely:
//!
//! - **who is in the order** (`members`): the articles of the section that a
//! person can see — a text in force, or an open version. A draft is nobody's
//! but its author's; an article whose versions were all withdrawn, or that
//! was removed with nothing left open, is out;
//! - **an order proposed** names every article of the section, each once
//! (`check`);
//! - **articles come and go** while an order is under vote: the proposal is
//! **completed at the end** (`fit`). The articles it names that are still in
//! the section keep its order; those that entered since go at the end, in the
//! order of the section; those that left are dropped. Its votes stay: the
//! voters chose the relative order of the articles they saw, and a newcomer
//! at the end is where a new article goes anyway (`place_at_end`). Neither
//! lapsing nor asking every voter again would let an order ever pass in a
//! section that lives;
//! - an order that, so completed, is the order in force changes nothing and
//! cannot enter into force (the engine compares `canonical` texts);
//! - **entering into force** gives the section's places out again in the new
//! order (`positions`): the section keeps the positions it holds, so it never
//! mingles with its neighbours;
//! - an order of fewer than two articles has nothing to decide (`is_moot`).
//!
//! The vote itself — threshold, margin over the version in force (ADR 0002) —
//! is that of any version (`compose`).
/// An article as the order of its section sees it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Article<'a> {
pub id: &'a str,
pub section: Option<&'a str>,
pub position: f64,
/// A text in force, or at least one open version (text or removal).
pub visible: bool,
}
/// Below this many articles, an order has nothing to decide.
pub const MIN_ARTICLES: usize = 2;
pub fn is_moot(members: &[String]) -> bool {
members.len() < MIN_ARTICLES
}
/// The articles in the order of `section`, as it reads now: by position, then id.
pub fn members(articles: &[Article<'_>], section: &str) -> Vec<String> {
let mut inside: Vec<&Article<'_>> = articles.iter().filter(|a| a.visible && a.section == Some(section)).collect();
inside.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(b.id)));
inside.into_iter().map(|a| a.id.to_owned()).collect()
}
/// What an order proposal gives the section now.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Fitted {
/// The order as it would read now.
pub order: Vec<String>,
/// Articles that entered the section since it was proposed: at the end.
pub added: Vec<String>,
/// Articles it names that left the section: dropped.
pub dropped: Vec<String>,
}
/// An order proposal against the section as it is now: completed at the end.
pub fn fit(proposed: &[String], members: &[String]) -> Fitted {
let mut order: Vec<String> = Vec::with_capacity(members.len());
let mut dropped = Vec::new();
for id in proposed {
if order.contains(id) {
continue;
}
if members.contains(id) {
order.push(id.clone());
} else if !dropped.contains(id) {
dropped.push(id.clone());
}
}
let added: Vec<String> = members.iter().filter(|m| !order.contains(m)).cloned().collect();
order.extend(added.iter().cloned());
Fitted { order, added, dropped }
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OrderError {
/// Not every article of the section, each once.
NotTheSection,
/// The order the section already reads in.
Unchanged,
}
/// An order proposed now: every article of the section, each once, in another order.
pub fn check(proposed: &[String], members: &[String]) -> Result<(), OrderError> {
let mut seen: Vec<&String> = Vec::with_capacity(proposed.len());
for id in proposed {
if seen.contains(&id) || !members.contains(id) {
return Err(OrderError::NotTheSection);
}
seen.push(id);
}
if proposed.len() != members.len() {
return Err(OrderError::NotTheSection);
}
if proposed == members {
return Err(OrderError::Unchanged);
}
Ok(())
}
/// The order as a text: one article per line. It is the body of an order
/// proposal, and what the engine compares to tell an order from another.
pub fn canonical(order: &[String]) -> String {
order.join("\n")
}
/// The order a proposal's body holds; `None` when it holds none.
pub fn decode(body: &str) -> Option<Vec<String>> {
let ids: Vec<String> = body.lines().map(str::trim).map(str::to_owned).collect();
let readable = !ids.is_empty() && ids.iter().all(|id| !id.is_empty() && !id.contains(char::is_whitespace));
readable.then_some(ids)
}
/// The places of the section, given out again in the new order: the section
/// keeps the positions it holds (`current`, any order), the first of them goes
/// to the first article of `order`, and so on. Articles of `order` that hold no
/// place, and places beyond `order`, are left alone.
pub fn positions(order: &[String], current: &[(String, f64)]) -> Vec<(String, f64)> {
let ranked: Vec<&String> = order.iter().filter(|id| current.iter().any(|(c, _)| c == *id)).collect();
let mut places: Vec<f64> = current.iter().filter(|(c, _)| ranked.contains(&c)).map(|(_, p)| *p).collect();
places.sort_by(f64::total_cmp);
ranked.into_iter().zip(places).map(|(id, p)| (id.clone(), p)).collect()
}
/// Where a new article goes: at the end of its section — after the last article
/// of the section (seen or not: a draft keeps its place), before the first one
/// that follows it in the document. Without a section, or in a section not seen
/// yet, after everything.
pub fn place_at_end(articles: &[(Option<&str>, f64)], section: Option<&str>) -> f64 {
let last_of_doc = articles.iter().map(|(_, p)| *p).fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.max(p))));
let after_all = last_of_doc.map_or(1.0, |m| m + 1.0);
let Some(section) = section else { return after_all };
let Some(last) = articles
.iter()
.filter(|(s, _)| *s == Some(section))
.map(|(_, p)| *p)
.fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.max(p))))
else {
return after_all;
};
let next = articles
.iter()
.map(|(_, p)| *p)
.filter(|p| *p > last)
.fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.min(p))));
next.map_or(last + 1.0, |next| (last + next) / 2.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_owned()).collect()
}
fn art<'a>(id: &'a str, section: &'a str, position: f64, visible: bool) -> Article<'a> {
Article { id, section: Some(section), position, visible }
}
#[test]
fn the_order_holds_the_articles_one_can_see_in_the_order_they_read() {
let doc = [
art("I1", "introduction", 1.0, true),
art("T1", "fondamental", 3.0, true),
art("E2", "fondamental", 5.0, true),
art("E1", "fondamental", 4.0, true),
// A draft, or an article with nothing left open: out.
art("N1", "fondamental", 5.5, false),
];
assert_eq!(members(&doc, "fondamental"), ids(&["T1", "E1", "E2"]));
assert_eq!(members(&doc, "introduction"), ids(&["I1"]));
assert!(members(&doc, "annexe").is_empty());
// The same place: the identifier settles it.
let tie = [art("b", "s", 1.0, true), art("a", "s", 1.0, true)];
assert_eq!(members(&tie, "s"), ids(&["a", "b"]));
}
#[test]
fn an_order_of_fewer_than_two_articles_has_nothing_to_decide() {
assert!(is_moot(&[]));
assert!(is_moot(&ids(&["O1"])));
assert!(!is_moot(&ids(&["K1", "K2"])));
}
#[test]
fn an_order_is_proposed_with_every_article_of_the_section_each_once() {
let section = ids(&["T1", "E1", "E2"]);
assert_eq!(check(&ids(&["E2", "E1", "T1"]), &section), Ok(()));
assert_eq!(check(&ids(&["T1", "E1", "E2"]), &section), Err(OrderError::Unchanged));
for wrong in [&["E2", "E1"][..], &["E2", "E1", "T1", "T1"], &["E2", "E1", "X9"], &["E2", "E2", "T1"], &[]] {
assert_eq!(check(&ids(wrong), &section), Err(OrderError::NotTheSection), "{wrong:?}");
}
}
#[test]
fn a_proposal_still_fitting_the_section_is_read_as_it_was_proposed() {
let section = ids(&["T1", "E1", "E2"]);
let f = fit(&ids(&["E2", "T1", "E1"]), &section);
assert_eq!(f, Fitted { order: ids(&["E2", "T1", "E1"]), added: vec![], dropped: vec![] });
}
#[test]
fn an_article_that_enters_the_section_goes_at_the_end_of_a_proposal() {
// Proposed when the section held T1, E1, E2; N4 then N7 entered since.
let now = ids(&["T1", "N4", "E1", "E2", "N7"]);
let f = fit(&ids(&["E2", "E1", "T1"]), &now);
assert_eq!(f.order, ids(&["E2", "E1", "T1", "N4", "N7"]), "the newcomers at the end, in the section's order");
assert_eq!(f.added, ids(&["N4", "N7"]));
assert!(f.dropped.is_empty());
}
#[test]
fn an_article_that_leaves_the_section_is_dropped_from_a_proposal() {
let now = ids(&["T1", "E2"]);
let f = fit(&ids(&["E2", "E1", "T1"]), &now);
assert_eq!(f.order, ids(&["E2", "T1"]));
assert_eq!(f.dropped, ids(&["E1"]));
assert!(f.added.is_empty());
// Both at once; a repeated or unknown line never doubles an article.
let f = fit(&ids(&["E2", "E2", "E1", "X1", "T1"]), &ids(&["T1", "E2", "N4"]));
assert_eq!((f.order, f.added, f.dropped), (ids(&["E2", "T1", "N4"]), ids(&["N4"]), ids(&["E1", "X1"])));
}
#[test]
fn a_proposal_that_comes_back_to_the_order_in_force_changes_nothing() {
// [E1, T1] proposed over [T1, E1]; E1 left: completed, it is the order in force.
let now = ids(&["T1"]);
assert_eq!(canonical(&fit(&ids(&["E1", "T1"]), &now).order), canonical(&now));
}
#[test]
fn the_body_of_an_order_proposal_is_one_article_per_line() {
let order = ids(&["a1f0", "77c2", "09bd"]);
assert_eq!(canonical(&order), "a1f0\n77c2\n09bd");
assert_eq!(decode(&canonical(&order)), Some(order));
assert_eq!(decode("a1\r\nb2"), Some(ids(&["a1", "b2"])));
for unreadable in ["", "a1\n\nb2", "a1 b2", "\n"] {
assert_eq!(decode(unreadable), None, "{unreadable:?}");
}
}
#[test]
fn entering_into_force_gives_the_places_of_the_section_out_again() {
let current = vec![("T1".to_owned(), 3.0), ("E1".to_owned(), 4.0), ("E2".to_owned(), 5.0)];
let mut moved = positions(&ids(&["E2", "T1", "E1"]), &current);
moved.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(moved, vec![("E1".to_owned(), 5.0), ("E2".to_owned(), 3.0), ("T1".to_owned(), 4.0)]);
// Uneven places stay the section's own: the neighbours never mingle with it.
let uneven = vec![("a".to_owned(), 6.0), ("b".to_owned(), 6.5), ("c".to_owned(), 11.0)];
assert_eq!(
positions(&ids(&["c", "a", "b"]), &uneven),
vec![("c".to_owned(), 6.0), ("a".to_owned(), 6.5), ("b".to_owned(), 11.0)]
);
// An article of the order without a place is left out; a place without an article is kept.
assert_eq!(positions(&ids(&["z", "b"]), &uneven), vec![("b".to_owned(), 6.5)]);
}
#[test]
fn an_order_is_voted_like_any_version_with_the_margin_of_the_one_in_force() {
use crate::compose::{ProposalKind, ProposalState, UnitState, VersionText, adoptions};
use crate::inertia::{InertiaLevel, Scale};
use crate::threshold::Tally;
// Monnaie libre before any median: W 5745, C 50; the order of a section at level 2.
let scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 };
let section = ids(&["T1", "E1", "E2"]);
let module = |tally: Tally| UnitState {
id: "ordre.fondamental".into(),
position: 0.0,
inertia: InertiaLevel::new(2).expect("level"),
in_force: Some(VersionText { id: "seed".into(), title: String::new(), body: canonical(&section), tally }),
};
let proposal = |id: &str, order: &[&str], for_votes: u32| ProposalState {
id: id.into(),
unit_id: "ordre.fondamental".into(),
kind: ProposalKind::Text,
title: String::new(),
body: canonical(&fit(&ids(order), &section).order),
tally: Tally { for_votes, against: 0 },
submitted_at: "2026-10-31T00:00:00Z".into(),
blocked: false,
};
let seed = [module(Tally::default())];
// Never under C voices for; the order in force at zero votes is a seed a minima: margin −C.
assert!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 49)], &scale).expect("valid").is_empty());
assert_eq!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 50)], &scale).expect("valid"), ["p"]);
// An order that is the one in force changes nothing, whatever its votes.
assert!(adoptions(&seed, &[proposal("same", &["T1", "E1", "E2"], 80)], &scale).expect("valid").is_empty());
// Adopted by a vote, the order in force holds its margin: a weaker order waits, a stronger one passes.
let voted = [module(Tally { for_votes: 70, against: 0 })];
assert!(adoptions(&voted, &[proposal("w", &["E2", "E1", "T1"], 60)], &scale).expect("valid").is_empty());
assert_eq!(adoptions(&voted, &[proposal("s", &["E2", "E1", "T1"], 90)], &scale).expect("valid"), ["s"]);
}
#[test]
fn a_new_article_goes_at_the_end_of_its_section() {
let doc = [
(Some("introduction"), 1.0),
(Some("introduction"), 2.0),
(Some("fondamental"), 3.0),
(Some("fondamental"), 5.0),
(Some("technique"), 6.0),
];
// Between the last of its section and the first that follows it.
assert_eq!(place_at_end(&doc, Some("introduction")), 2.5);
assert_eq!(place_at_end(&doc, Some("fondamental")), 5.5);
// The last section of the document, an unknown section, or none: after everything.
assert_eq!(place_at_end(&doc, Some("technique")), 7.0);
assert_eq!(place_at_end(&doc, Some("annexe")), 7.0);
assert_eq!(place_at_end(&doc, None), 7.0);
assert_eq!(place_at_end(&[], Some("introduction")), 1.0);
// After the ones already placed at its end.
let again = [(Some("fondamental"), 5.0), (Some("fondamental"), 5.5), (Some("technique"), 6.0)];
assert_eq!(place_at_end(&again, Some("fondamental")), 5.75);
}
}
+30 -3
View File
@@ -1,9 +1,11 @@
//! What is left to vote in a document — the line of « Décisions ».
//!
//! A module is an article (a unit). It stays **to vote** as long as no version
//! A module is an article, the title of the document, or the order of a
//! section (ADR 0009) — a unit. It stays **to vote** as long as no version
//! of it was adopted by a vote: its version in force is a seed a minima not yet
//! confirmed, or it has no version in force and at least one open version.
//! Variants of an article count once. For a person who votes, a module is
//! Variants of an article count once. The order of a section of fewer than two
//! articles has nothing to decide: never to vote. For a person who votes, a module is
//! **without their vote** when they hold no valid vote (one not « to
//! reconfirm ») on any of its versions.
@@ -17,10 +19,16 @@ pub struct Module {
pub open: u32,
/// The person holds a valid vote on one of its versions, in force or open.
pub voted: bool,
/// Nothing to decide: the order of a section of fewer than two articles
/// (`order::is_moot`). Never to vote, and not shown.
pub moot: bool,
}
impl Module {
pub fn to_vote(&self) -> bool {
if self.moot {
return false;
}
match self.in_force {
Some(adopted) => !adopted,
None => self.open > 0,
@@ -51,7 +59,7 @@ mod tests {
const ADOPTED: Option<bool> = Some(true);
fn m(in_force: Option<bool>, open: u32, voted: bool) -> Module {
Module { in_force, open, voted }
Module { in_force, open, voted, moot: false }
}
#[test]
@@ -88,6 +96,25 @@ mod tests {
assert_eq!(pending(&modules), Pending { to_vote: 31, without_my_vote: 30 });
}
#[test]
fn an_order_with_nothing_to_decide_is_never_to_vote() {
let one_article = Module { moot: true, ..m(SEED, 1, false) };
assert!(!one_article.to_vote());
assert_eq!(pending(&[one_article]), Pending::default());
}
#[test]
fn the_modules_of_the_certification_act() {
// 33 articles; the title of the document; the order of each of the 8 sections,
// all seeds a minima — but « ordonnancement » holds one article: 41 to vote.
let mut modules = vec![m(SEED, 0, false); 3];
modules.extend(vec![m(None, 1, false); 30]);
modules.push(m(SEED, 0, false));
modules.extend(vec![m(SEED, 0, false); 7]);
modules.push(Module { moot: true, ..m(SEED, 0, false) });
assert_eq!(pending(&modules), Pending { to_vote: 41, without_my_vote: 41 });
}
#[test]
fn nothing_to_vote_in_an_empty_document() {
assert_eq!(pending(&[]), Pending::default());
@@ -0,0 +1,26 @@
-- Modules (2026-10-31, ADR 0009): the title of a document and the order of the
-- articles of each of its sections, voted like its articles. Additive only:
-- every unit of before is an article, nothing else changes.
--
-- A module is a unit of its own kind. Its versions are proposals, voted,
-- counted, signed on the chain and brought into force like those of an
-- article; their kind says what they hold:
-- title — `title` is the title of the document in its source language;
-- order — `body` is the order of the section, one article (unit id) per line.
ALTER TABLE units ADD COLUMN kind TEXT NOT NULL DEFAULT 'article'; -- article | title | order
-- The modules added to an existing document (src/module_amend.rs): decided
-- once per document, from what it holds then (its title, the order of each
-- section), never touching an article or a vote. Each decision is kept here:
-- applied (and what was created), refused (and why), or present (the document
-- was born with its modules).
CREATE TABLE module_amendments (
amendment_id TEXT NOT NULL,
doc_id TEXT NOT NULL REFERENCES documents(id),
outcome TEXT NOT NULL, -- applied | refused | present
reason TEXT, -- why it was refused
created TEXT, -- JSON array of the codes of the modules created
at TEXT NOT NULL,
PRIMARY KEY (amendment_id, doc_id)
);
+4 -1
View File
@@ -28,6 +28,8 @@ pub struct Entered {
}
pub async fn enqueue_publication(state: &AppState, doc: &DocRow, reason: &str) -> AppResult<()> {
// A text in force changed: the document's title follows its title in force (ADR 0009).
crate::doc_title::sync(state, &doc.id).await?;
if doc.publish_path.is_none() {
return Ok(());
}
@@ -118,7 +120,8 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
.bind(&unit.id)
.execute(&mut *tx)
.await?;
"text"
// A module does more: an order moves its articles, a title renames the document.
crate::modules::on_entry(&mut tx, &st, unit, p).await?
} else {
sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?")
.bind(&now)
+171
View File
@@ -0,0 +1,171 @@
//! The title of a document, a module (ADR 0009, level 4).
//!
//! Its version in force is the title in the source language, the one that
//! holds. `documents.title` keeps the title as every list and the publication
//! read it: the source language from the version in force, the other languages
//! from its editorial translations (`sync`).
//!
//! When another title enters into force, the document changes title in the
//! source language. Its translations stay editorial (Yvv, 2026-10-21: the
//! French is voted, the translations are not): the new version takes those of
//! the title it replaces, pinned to that title's text, so they read « à
//! revoir » until a member translates the new one.
use crate::AppState;
use crate::auth::User;
use crate::docs::{self, ProposalRow, UnitRow};
use crate::error::{AppError, AppResult};
use crate::modules::{self, ModuleKind, NewVersion};
use crate::util::{self, LangMap};
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::edit;
use serde::Deserialize;
use serde_json::{Value, json};
use sqlx::SqliteConnection;
/// The longest title, as for an article.
const MAX_TITLE: usize = 120;
/// The translations of the title a document starts with, from its seed or its row.
pub async fn seed_translations(
conn: &mut SqliteConnection,
proposal_id: &str,
title: &str,
titles: &LangMap,
src: &str,
now: &str,
) -> sqlx::Result<()> {
let hash = util::source_hash(title, "");
for lang in util::LANGS.iter().filter(|l| **l != src) {
let Some(t) = titles.get(*lang).filter(|t| !t.trim().is_empty()) else { continue };
sqlx::query(
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
VALUES (?, ?, ?, '', ?, 'seed', NULL, ?)",
)
.bind(proposal_id)
.bind(lang)
.bind(t)
.bind(&hash)
.bind(now)
.execute(&mut *conn)
.await?;
}
Ok(())
}
/// `documents.title` from the title in force and its translations; a language
/// the version in force does not translate keeps the title it had.
pub async fn sync_conn(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<()> {
let row: Option<(String, String, String, String)> = sqlx::query_as(
"SELECT p.id, p.title, d.title, d.source_lang FROM units u
JOIN proposals p ON p.id = u.in_force_id JOIN documents d ON d.id = u.doc_id
WHERE u.doc_id = ? AND u.kind = 'title'",
)
.bind(doc_id)
.fetch_optional(&mut *conn)
.await?;
let Some((pid, title, raw, src)) = row else { return Ok(()) };
let mut titles = util::parse_lang(&raw);
titles.insert(src, title);
let translated: Vec<(String, String)> =
sqlx::query_as("SELECT lang, title FROM translations WHERE proposal_id = ?")
.bind(&pid)
.fetch_all(&mut *conn)
.await?;
for (lang, t) in translated {
titles.insert(lang, t);
}
let next = util::lang_json(&titles);
if next != raw {
sqlx::query("UPDATE documents SET title = ? WHERE id = ?").bind(&next).bind(doc_id).execute(&mut *conn).await?;
}
Ok(())
}
/// After any change to a text in force (a typo, a translation, an entry): the
/// document's title follows its title in force.
pub async fn sync(state: &AppState, doc_id: &str) -> sqlx::Result<()> {
let mut conn = state.db.acquire().await?;
sync_conn(&mut conn, doc_id).await
}
/// Another title enters into force: it takes the translations of the one it
/// replaces where it has none — « à revoir », pinned to the former title — and
/// becomes the document's title.
pub async fn enter(conn: &mut SqliteConnection, doc_id: &str, unit: &UnitRow, p: &ProposalRow) -> AppResult<()> {
if let Some(before) = unit.in_force_id.as_deref().filter(|b| *b != p.id) {
sqlx::query(
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
SELECT ?1, t.lang, t.title, t.body, t.source_hash, t.origin, t.updated_by, t.updated_at
FROM translations t
WHERE t.proposal_id = ?2
AND NOT EXISTS (SELECT 1 FROM translations n WHERE n.proposal_id = ?1 AND n.lang = t.lang)",
)
.bind(&p.id)
.bind(before)
.execute(&mut *conn)
.await?;
}
sync_conn(conn, doc_id).await?;
tracing::info!(doc = %doc_id, proposal = %p.id, "title of the document in force");
Ok(())
}
#[derive(Deserialize)]
pub struct TitleBody {
title: String,
}
/// Propose another title for the document. An open proposal of the same title
/// 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<TitleBody>,
) -> 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::Title) {
return Err(AppError::Invalid("not_a_title"));
}
if edit::has_hidden_controls(&b.title) {
return Err(AppError::Invalid("hidden_characters"));
}
let title = b.title.trim();
if title.is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) {
return Err(AppError::Invalid("title"));
}
let in_force = unit.in_force_id.as_deref().and_then(|id| st.proposals.iter().find(|p| p.id == id));
if in_force.is_some_and(|p| p.title == title) {
return Err(AppError::Invalid("same_title"));
}
if let Some(existing) = st.proposals.iter().find(|p| {
p.unit_id == unit.id && p.status == "open" && p.kind == ModuleKind::Title.as_str() && p.title == title
}) {
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::Title,
title,
body: "",
author: Some(&user.id),
in_force: false,
now: &now,
},
)
.await?;
Ok(Json(json!({ "id": id, "existing": false })))
}
+164 -39
View File
@@ -7,6 +7,7 @@ use crate::AppState;
use crate::auth::{IdentityRow, User};
use crate::ballots::{self, MyBallot, MyVote};
use crate::error::{AppError, AppResult};
use crate::modules::ModuleKind;
use crate::participation;
use crate::util::{self, LangMap};
use axum::Json;
@@ -146,9 +147,10 @@ pub async fn group(
let docs = if open {
sqlx::query_as::<_, DocListRow>(
"SELECT d.id, d.slug, d.title, d.summary,
(SELECT COUNT(*) FROM units u WHERE u.doc_id = d.id AND u.in_force_id IS NOT NULL) AS in_force,
(SELECT COUNT(*) FROM units u
WHERE u.doc_id = d.id AND u.kind = 'article' AND u.in_force_id IS NOT NULL) AS in_force,
(SELECT COUNT(*) FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = d.id AND p.status = 'open') AS open
WHERE u.doc_id = d.id AND u.kind = 'article' AND p.status = 'open') AS open
FROM documents d WHERE d.group_slug = ? ORDER BY d.created_at",
)
.bind(&slug)
@@ -250,7 +252,10 @@ pub fn inertia_of(unit: &UnitRow) -> InertiaLevel {
pub struct DocState {
pub doc: DocRow,
pub group: GroupRow,
/// Every unit, articles and modules, by position.
pub units: Vec<UnitRow>,
/// The units that are modules (ADR 0009), by unit id; any other is an article.
pub module_kinds: HashMap<String, ModuleKind>,
pub proposals: Vec<ProposalRow>,
pub tallies: HashMap<String, Tally>,
pub blocked: HashSet<String>,
@@ -296,6 +301,13 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let kinds: Vec<(String, String)> =
sqlx::query_as("SELECT id, kind FROM units WHERE doc_id = ? AND kind <> 'article'")
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let module_kinds: HashMap<String, ModuleKind> =
kinds.into_iter().filter_map(|(id, kind)| Some((id, ModuleKind::parse(&kind)?))).collect();
// Same second (the variants of a seed): the order they were written in, not their
// random identifiers. The engine ranks rivals on its own; this order is what people see.
let proposals = sqlx::query_as::<_, ProposalRow>(&format!(
@@ -370,6 +382,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
doc,
group,
units,
module_kinds,
proposals,
tallies,
blocked,
@@ -396,38 +409,126 @@ impl DocState {
self.tallies.get(id).copied().unwrap_or_default()
}
/// The module a unit is, if it is one.
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
self.module_kinds.get(unit_id).copied()
}
/// The articles, by position: every unit that is no module.
pub fn articles(&self) -> impl Iterator<Item = &UnitRow> {
self.units.iter().filter(|u| !self.module_kinds.contains_key(&u.id))
}
/// The modules of a kind; the orders in the order of the document's sections.
pub fn modules(&self, kind: ModuleKind) -> Vec<&UnitRow> {
let mut out: Vec<&UnitRow> = self.units.iter().filter(|u| self.module_of(&u.id) == Some(kind)).collect();
let sections = self.sections();
let rank = |u: &UnitRow| u.section.as_ref().and_then(|s| sections.iter().position(|x| x == s));
out.sort_by_key(|u| (rank(u).unwrap_or(usize::MAX), u.code.clone()));
out
}
/// The sections of the document, in the order its articles read in.
pub fn sections(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for u in self.articles() {
if let Some(s) = &u.section
&& !out.contains(s)
{
out.push(s.clone());
}
}
out
}
/// The articles in the order of `section` now: those one can see — a text in
/// force, or an open version — by position (`ld_engine::order::members`;
/// `order::sections_now` says the same in SQL).
pub fn members(&self, section: &str) -> Vec<String> {
let open: HashSet<&str> = self
.proposals
.iter()
.filter(|p| p.status == "open" && matches!(p.kind.as_str(), "text" | "remove"))
.map(|p| p.unit_id.as_str())
.collect();
let articles: Vec<ld_engine::order::Article<'_>> = self
.articles()
.map(|u| ld_engine::order::Article {
id: &u.id,
section: u.section.as_deref(),
position: u.position,
visible: u.in_force_id.is_some() || open.contains(u.id.as_str()),
})
.collect();
ld_engine::order::members(&articles, section)
}
/// The order each order module's section reads in now, by unit id.
fn live_orders(&self) -> HashMap<String, Vec<String>> {
self.modules(ModuleKind::Order)
.into_iter()
.map(|u| (u.id.clone(), self.members(u.section.as_deref().unwrap_or_default())))
.collect()
}
/// The units as the engine sees them. The version in force of an order is
/// the order its section reads in now: the engine compares orders as texts.
pub fn unit_states(&self) -> Vec<UnitState> {
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
let live = self.live_orders();
self.units
.iter()
.map(|u| UnitState {
id: u.id.clone(),
position: u.position,
inertia: inertia_of(u),
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| VersionText {
id: p.id.clone(),
title: p.title.clone(),
body: p.body.clone(),
tally: self.tally(&p.id),
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| match live.get(&u.id) {
Some(order) => VersionText {
id: p.id.clone(),
title: String::new(),
body: ld_engine::order::canonical(order),
tally: self.tally(&p.id),
},
None => VersionText {
id: p.id.clone(),
title: p.title.clone(),
body: p.body.clone(),
tally: self.tally(&p.id),
},
}),
})
.collect()
}
/// Open proposals only — the in-force versions compete as incumbents.
/// Open proposals only — the in-force versions compete as incumbents. An
/// order proposal is read as it fits its section now (completed at the end);
/// one that holds no order is blocked.
pub fn proposal_states(&self) -> Vec<ProposalState> {
let live = self.live_orders();
self.proposals
.iter()
.filter(|p| p.status == "open")
.map(|p| ProposalState {
id: p.id.clone(),
unit_id: p.unit_id.clone(),
kind: p.kind(),
title: p.title.clone(),
body: p.body.clone(),
tally: self.tally(&p.id),
submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()),
blocked: self.blocked.contains(&p.id),
.map(|p| {
let mut s = ProposalState {
id: p.id.clone(),
unit_id: p.unit_id.clone(),
kind: p.kind(),
title: p.title.clone(),
body: p.body.clone(),
tally: self.tally(&p.id),
submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()),
blocked: self.blocked.contains(&p.id),
};
if let Some(members) = live.get(&p.unit_id).filter(|_| p.kind == ModuleKind::Order.as_str()) {
s.title = String::new();
match ld_engine::order::decode(&p.body) {
Some(order) => {
s.body = ld_engine::order::canonical(&ld_engine::order::fit(&order, members).order);
}
None => s.blocked = true,
}
}
s
})
.collect()
}
@@ -527,6 +628,8 @@ fn proposal_json(
let (mine, ballot) = mine;
let eval = st.evaluation(unit, p);
let (kind, target) = match p.kind() {
// A module's version says what it holds (ADR 0009).
ProposalKind::Text if matches!(p.kind.as_str(), "order" | "title") => (p.kind.as_str(), None),
ProposalKind::Text => ("text", None),
ProposalKind::Remove => ("remove", None),
ProposalKind::Inertia(t) => ("inertia", Some(t)),
@@ -572,28 +675,47 @@ pub async fn document(
let pending = ballots::my_ballots(&state, &user.id, &id).await?;
let changes = inertia_changes(&state, &id).await?;
let units: Vec<_> = st
.units
.iter()
.map(|u| {
let of_unit: Vec<_> = visible
.iter()
.filter(|p| p.unit_id == u.id)
.map(|p| {
let of = (mine.get(&p.id), pending.get(&p.id));
proposal_json(&st, u, p, of, translations.get(&p.id), &user.id)
})
.collect();
let in_force = u
.in_force_id
.as_ref()
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
json!({
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u),
"inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(),
"inForce": in_force, "proposals": proposals,
let unit_json = |u: &UnitRow| {
let of_unit: Vec<_> = visible
.iter()
.filter(|p| p.unit_id == u.id)
.map(|p| {
let of = (mine.get(&p.id), pending.get(&p.id));
let mut j = proposal_json(&st, u, p, of, translations.get(&p.id), &user.id);
if st.module_of(&u.id) == Some(ModuleKind::Order) {
j["order"] = crate::order::version_json(&st, u, p);
}
j
})
.collect();
let in_force = u
.in_force_id
.as_ref()
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
json!({
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u),
"inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(),
"inForce": in_force, "proposals": proposals,
})
};
let units: Vec<_> = st.articles().map(unit_json).collect();
// The modules (ADR 0009): the title of the document, the order of each section.
let title = st.modules(ModuleKind::Title).first().map(|u| {
let mut j = unit_json(u);
j["kind"] = json!("title");
j
});
let orders: Vec<_> = st
.modules(ModuleKind::Order)
.into_iter()
.map(|u| {
let members = st.members(u.section.as_deref().unwrap_or_default());
let mut j = unit_json(u);
j["kind"] = json!("order");
j["moot"] = json!(ld_engine::order::is_moot(&members));
j["members"] = json!(members);
j
})
.collect();
@@ -612,6 +734,7 @@ pub async fn document(
"publication": publication,
},
"units": units,
"modules": { "title": title, "orders": orders },
// The five levels, with the *for* votes asked at the usual participation.
"levels": st.levels_json(st.usual_voters()),
})))
@@ -687,7 +810,9 @@ pub async fn preview(
Some("wellrated") => PreviewMode::WellRated,
_ => PreviewMode::InForce,
};
let units = compose::preview(&st.unit_states(), &st.proposal_states(), &st.scale(), mode)
// The articles only: a module changes no text of the preview.
let articles: Vec<UnitState> = st.unit_states().into_iter().filter(|u| st.module_of(&u.id).is_none()).collect();
let units = compose::preview(&articles, &st.proposal_states(), &st.scale(), mode)
.map_err(|_| AppError::Invalid("electorate"))?;
// Translations of the versions shown.
let shown: HashSet<String> = units
+6
View File
@@ -11,10 +11,14 @@ pub mod adoption;
pub mod auth;
pub mod ballots;
pub mod config;
pub mod doc_title;
pub mod docs;
pub mod error;
pub mod indexer;
pub mod inertia_amend;
pub mod module_amend;
pub mod modules;
pub mod order;
pub mod overview;
pub mod participation;
pub mod proposals;
@@ -131,6 +135,8 @@ pub fn api_router() -> Router<AppState> {
.route("/proposals/{id}/translations/{lang}", put(proposals::translate))
.route("/signals/{id}/lift", post(proposals::lift_signal))
.route("/units/{id}/inertia", post(proposals::propose_inertia))
.route("/units/{id}/order", post(order::propose))
.route("/units/{id}/title", post(doc_title::propose))
.route("/feedback", post(access::send_feedback))
.route("/access", get(access::overview))
.route("/access/groups/{slug}/members", post(access::grant))
+221
View File
@@ -0,0 +1,221 @@
//! Module amendments — the modules a seed now declares, added to a document
//! that was born without them (ADR 0009).
//!
//! Yvv, 2026-10-30: the title of the document at level 4, the order of the
//! articles of each section at level 2. A new base gets them from the seed
//! (`modules` in the seed file). An existing base — production holds two
//! instances of the certification act, Monnaie libre and the test group — gets
//! them through an amendment listed here, built **from what the document holds
//! then**: its title as it reads, and the order each section reads in. Nothing
//! else is written: no article moves, no text changes, no vote is touched.
//!
//! Like the seed and inertia amendments, each document is decided once, at the
//! first start that knows the amendment, and the decision is kept in
//! `module_amendments` (applied with the codes created, refused and why, or
//! present) and in the logs. Applied where the document publishes, the
//! publication queue gets a row (`document.json` carries the modules).
use crate::AppState;
use crate::modules::{self, Levels};
use crate::seed::SeedDoc;
use crate::util;
use ld_engine::inertia::InertiaLevel;
use sqlx::SqliteConnection;
/// Modules to add to every document instantiated from a seed.
pub struct ModuleAmendment {
/// Stable identifier: each document's decision is kept under it.
pub id: &'static str,
/// Slug of the seed document.
pub doc: &'static str,
/// The level of the title module: the seed's today, which must say so.
pub title: u8,
/// The level of the order modules: the seed's today, which must say so.
pub order: u8,
/// Reason given to the publication queue, and so to the commit of the texts.
pub reason: &'static str,
}
pub const AMENDMENTS: &[ModuleAmendment] = &[ModuleAmendment {
id: "2026-10-31-modules-titre-ordre",
doc: "acte-engagement-certification",
title: 4,
order: 2,
reason: "modules ajoutés : le titre du document, l'ordre des articles de chaque section",
}];
/// What the database says of the document.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Facts {
/// Its title in its source language.
pub title: String,
/// Modules it holds already, of either kind.
pub modules: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Apply,
/// Born with its modules: nothing to do.
Present,
Refuse(&'static str),
}
/// The rule, apart from the database.
pub fn verdict(facts: Option<&Facts>) -> Verdict {
let Some(f) = facts else { return Verdict::Refuse("no_document") };
if f.modules > 0 {
Verdict::Present
} else if f.title.trim().is_empty() {
Verdict::Refuse("no_title")
} else {
Verdict::Apply
}
}
/// A decision taken at this start.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub amendment: &'static str,
pub group: String,
pub verdict: Verdict,
pub created: Vec<String>,
}
/// Decides every amendment not yet decided, document by document.
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[ModuleAmendment]) -> anyhow::Result<Vec<Outcome>> {
let mut out = Vec::new();
for a in list {
let Some(levels) = seed_says(seeds, a) else { continue };
let docs: Vec<(String, String, Option<String>)> =
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
.bind(a.doc)
.fetch_all(&state.db)
.await?;
for (doc_id, group, publish_path) in docs {
let Some((v, created)) = decide(state, a, levels, &doc_id, publish_path.is_some()).await? else {
continue;
};
match v {
Verdict::Apply => tracing::info!(amendment = a.id, %group, created = ?created, "modules added"),
Verdict::Present => tracing::info!(amendment = a.id, %group, "modules: already there"),
Verdict::Refuse(why) => {
tracing::warn!(amendment = a.id, %group, reason = why, "modules refused: the document stays as it is")
}
}
if v == Verdict::Apply && publish_path.is_some() {
state.publish_wake.notify_one();
}
out.push(Outcome { amendment: a.id, group, verdict: v, created });
}
}
Ok(out)
}
/// The seed must declare the modules at the levels the amendment gives: a seed
/// edited later never reaches a base by this way.
fn seed_says(seeds: &[SeedDoc], a: &ModuleAmendment) -> Option<Levels> {
let levels = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.modules.as_ref()).map(Levels::from);
let wanted = Levels { title: InertiaLevel::new(a.title), order: InertiaLevel::new(a.order) };
match levels {
Some(l) if l == wanted && l.title.is_some() && l.order.is_some() => Some(l),
_ => {
tracing::error!(
amendment = a.id,
doc = a.doc,
"module amendment: the seed does not declare these modules, nothing decided"
);
None
}
}
}
async fn facts(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<Option<Facts>> {
let doc: Option<(String, String)> = sqlx::query_as("SELECT title, source_lang FROM documents WHERE id = ?")
.bind(doc_id)
.fetch_optional(&mut *conn)
.await?;
let Some((raw, src)) = doc else { return Ok(None) };
let modules: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind <> 'article'")
.bind(doc_id)
.fetch_one(&mut *conn)
.await?;
Ok(Some(Facts { title: util::parse_lang(&raw).get(&src).cloned().unwrap_or_default(), modules }))
}
/// One document, in one transaction: `None` when it was decided at an earlier start.
async fn decide(
state: &AppState,
a: &ModuleAmendment,
levels: Levels,
doc_id: &str,
publishes: bool,
) -> anyhow::Result<Option<(Verdict, Vec<String>)>> {
let mut tx = state.db.begin().await?;
let decided: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM module_amendments WHERE amendment_id = ? AND doc_id = ?")
.bind(a.id)
.bind(doc_id)
.fetch_one(&mut *tx)
.await?;
if decided > 0 {
return Ok(None);
}
let found = facts(&mut tx, doc_id).await?;
let v = verdict(found.as_ref());
let now = util::now();
let created = if v == Verdict::Apply { modules::create(&mut tx, doc_id, levels, &now).await? } else { Vec::new() };
if v == Verdict::Apply && publishes {
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
.bind(doc_id)
.bind(a.reason)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await?;
}
let (outcome, reason) = match v {
Verdict::Apply => ("applied", None),
Verdict::Present => ("present", None),
Verdict::Refuse(why) => ("refused", Some(why)),
};
sqlx::query(
"INSERT INTO module_amendments (amendment_id, doc_id, outcome, reason, created, at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(a.id)
.bind(doc_id)
.bind(outcome)
.bind(reason)
.bind((v == Verdict::Apply).then(|| serde_json::to_string(&created).unwrap_or_else(|_| "[]".into())))
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some((v, created)))
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(title: &str, modules: i64) -> Facts {
Facts { title: title.into(), modules }
}
#[test]
fn a_document_without_modules_receives_them() {
assert_eq!(verdict(Some(&doc("Acte d'engagement Certification", 0))), Verdict::Apply);
}
#[test]
fn a_document_born_with_its_modules_is_left_as_it_is() {
assert_eq!(verdict(Some(&doc("Acte", 9))), Verdict::Present);
assert_eq!(verdict(Some(&doc("Acte", 1))), Verdict::Present, "never a second set");
}
#[test]
fn nothing_without_a_document_or_a_title() {
assert_eq!(verdict(None), Verdict::Refuse("no_document"));
assert_eq!(verdict(Some(&doc(" ", 0))), Verdict::Refuse("no_title"));
}
}
+240
View File
@@ -0,0 +1,240 @@
//! Modules — what a document decides besides its articles (ADR 0009).
//!
//! Yvv, 2026-10-30: « En fin de section, il y a un "module" qui permet de
//! changer l'ordre des "articles". Lui-même à (2) » ; « Le titre du doc à (4) »
//! — and, asked whether the title of an article is voted apart: « Non, titre et
//! texte ensemble ». So two modules, each a unit of its own kind:
//!
//! - the **title of the document** (`title`, level 4): its version in force is
//! the title in the source language (`doc_title`);
//! - the **order of the articles of each section** (`order`, level 2): its
//! version in force is the order the section reads in now (`order`).
//!
//! A module is voted like an article — unfolded, in the basket, on the Ğ1
//! chain — because its versions are proposals like any other: the same
//! threshold, the same margin over the version in force, the same seed a
//! minima at the start. What differs is only what entering into force does.
//!
//! A new document gets its modules from the seed (`modules` in the seed file);
//! an existing one, once, from what it holds (`module_amend`). Both go through
//! `create`, which reads the document as it is and touches no article and no
//! vote.
use crate::docs::{DocState, ProposalRow, UnitRow};
use crate::error::{AppError, AppResult};
use crate::seed::SeedModules;
use crate::util;
use crate::{doc_title, order};
use ld_engine::inertia::InertiaLevel;
use sqlx::SqliteConnection;
/// The code of the title module of a document.
pub const TITLE_CODE: &str = "titre";
/// The code of the order module of a section.
pub fn order_code(section: &str) -> String {
format!("ordre.{section}")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ModuleKind {
Title,
Order,
}
impl ModuleKind {
/// `units.kind`; an article is no module.
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"title" => Some(Self::Title),
"order" => Some(Self::Order),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Title => "title",
Self::Order => "order",
}
}
}
/// The levels a document's modules start at; `None`: no such module.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Levels {
pub title: Option<InertiaLevel>,
pub order: Option<InertiaLevel>,
}
impl From<&SeedModules> for Levels {
fn from(m: &SeedModules) -> Self {
Self { title: m.title.as_ref().map(|t| t.inertia), order: m.order.as_ref().map(|o| o.inertia) }
}
}
/// A version of a module, written directly in force (a seed a minima) or open.
pub(crate) struct NewVersion<'a> {
pub unit_id: &'a str,
pub kind: ModuleKind,
pub title: &'a str,
pub body: &'a str,
pub author: Option<&'a str>,
pub in_force: bool,
pub now: &'a str,
}
pub(crate) async fn insert_version(conn: &mut SqliteConnection, v: NewVersion<'_>) -> sqlx::Result<String> {
let id = util::id();
let (status, origin) = if v.in_force { ("in_force", "seed") } else { ("open", "member") };
sqlx::query(
"INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision,
created_at, updated_at, submitted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, 1, ?, ?, ?)",
)
.bind(&id)
.bind(v.unit_id)
.bind(v.kind.as_str())
.bind(v.title)
.bind(v.body)
.bind(status)
.bind(origin)
.bind(v.author)
.bind(v.now)
.bind(v.now)
.bind(v.now)
.execute(&mut *conn)
.await?;
sqlx::query(
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
VALUES (?, 1, 'initial', ?, ?, ?, ?)",
)
.bind(&id)
.bind(v.title)
.bind(v.body)
.bind(v.author)
.bind(v.now)
.execute(&mut *conn)
.await?;
if v.in_force {
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
.bind(&id)
.bind(v.unit_id)
.execute(&mut *conn)
.await?;
}
Ok(id)
}
async fn insert_unit(
conn: &mut SqliteConnection,
doc_id: &str,
kind: ModuleKind,
code: &str,
section: Option<&str>,
level: InertiaLevel,
now: &str,
) -> sqlx::Result<String> {
let id = util::id();
// Its place is not among the articles: the reader puts each module where it belongs.
sqlx::query(
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at, kind)
VALUES (?, ?, ?, ?, 0, ?, NULL, ?, ?)",
)
.bind(&id)
.bind(doc_id)
.bind(code)
.bind(section)
.bind(level.as_db())
.bind(now)
.bind(kind.as_str())
.execute(&mut *conn)
.await?;
Ok(id)
}
/// Creates the modules of a document from what it holds now — its title, and
/// the order each of its sections reads in — each in force as a seed a minima.
/// No article, no vote is touched. Returns the codes created.
pub async fn create(conn: &mut SqliteConnection, doc_id: &str, levels: Levels, now: &str) -> sqlx::Result<Vec<String>> {
let mut created = Vec::new();
if let Some(level) = levels.title {
let (raw, src): (String, String) = sqlx::query_as("SELECT title, source_lang FROM documents WHERE id = ?")
.bind(doc_id)
.fetch_one(&mut *conn)
.await?;
let titles = util::parse_lang(&raw);
let title = titles.get(&src).cloned().unwrap_or_default();
let unit = insert_unit(conn, doc_id, ModuleKind::Title, TITLE_CODE, None, level, now).await?;
let pid = insert_version(
conn,
NewVersion {
unit_id: &unit,
kind: ModuleKind::Title,
title: &title,
body: "",
author: None,
in_force: true,
now,
},
)
.await?;
doc_title::seed_translations(conn, &pid, &title, &titles, &src, now).await?;
created.push(TITLE_CODE.to_owned());
}
if let Some(level) = levels.order {
for (section, members) in order::sections_now(conn, doc_id).await? {
let code = order_code(&section);
let unit = insert_unit(conn, doc_id, ModuleKind::Order, &code, Some(&section), level, now).await?;
let body = ld_engine::order::canonical(&members);
insert_version(
conn,
NewVersion {
unit_id: &unit,
kind: ModuleKind::Order,
title: &code,
body: &body,
author: None,
in_force: true,
now,
},
)
.await?;
created.push(code);
}
}
Ok(created)
}
/// What entering into force does, besides the version taking its place: the
/// order of a section moves its articles, the title of a document becomes its
/// title. Returns the kind kept in `adoptions` (`text` for an article).
pub async fn on_entry(
conn: &mut SqliteConnection,
st: &DocState,
unit: &UnitRow,
p: &ProposalRow,
) -> AppResult<&'static str> {
match st.module_of(&unit.id) {
None => Ok("text"),
Some(ModuleKind::Order) => {
order::enter(conn, st, unit, p).await?;
Ok("order")
}
Some(ModuleKind::Title) => {
doc_title::enter(conn, &st.doc.id, unit, p).await?;
Ok("title")
}
}
}
/// The generic writing of a version stops at a module: one proposes a module
/// through its own door (`order::propose`, `doc_title::propose`).
pub fn require_article(st: &DocState, unit_id: &str) -> AppResult<()> {
if st.module_of(unit_id).is_some() { Err(AppError::Invalid("module")) } else { Ok(()) }
}
/// An order is never corrected nor translated: another order is another proposal.
pub fn require_text(p: &ProposalRow) -> AppResult<()> {
if p.kind == ModuleKind::Order.as_str() { Err(AppError::Conflict("order_fixed")) } else { Ok(()) }
}
+166
View File
@@ -0,0 +1,166 @@
//! 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).
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 })))
}
+13 -4
View File
@@ -133,9 +133,11 @@ pub async fn decisions(
Ok(Json(json!({ "canVote": can_vote, "docs": out })))
}
/// The articles of a document as `me` sees them: its version in force (adopted
/// by a vote, or a seed a minima), its open versions, and whether `me` holds a
/// valid vote on one of them. A change of inertia is not a version of the text.
/// The modules of a document as `me` sees them — its articles, its title, the
/// order of each section (ADR 0009): the version in force (adopted by a vote,
/// or a seed a minima), the open versions, and whether `me` holds a valid vote
/// on one of them. A change of inertia is not a version of the text; the order
/// of a section of fewer than two articles has nothing to decide.
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
#[derive(sqlx::FromRow)]
struct VersionRow {
@@ -144,6 +146,13 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
adopted: bool,
voted: bool,
}
let st = docs::load_state(state, doc_id).await?;
let moot: HashSet<String> = st
.modules(crate::modules::ModuleKind::Order)
.into_iter()
.filter(|u| ld_engine::order::is_moot(&st.members(u.section.as_deref().unwrap_or_default())))
.map(|u| u.id.clone())
.collect();
let rows = sqlx::query_as::<_, VersionRow>(
"SELECT u.id AS unit_id,
IFNULL(p.id = u.in_force_id, 0) AS in_force,
@@ -164,7 +173,7 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
let mut current: Option<String> = None;
for r in rows {
if current.as_deref() != Some(r.unit_id.as_str()) {
out.push(Module::default());
out.push(Module { moot: moot.contains(&r.unit_id), ..Module::default() });
current = Some(r.unit_id);
}
if let Some(m) = out.last_mut() {
+10 -6
View File
@@ -2,8 +2,13 @@
//! of inertia (ADR 0007, `ld_engine::inertia`).
//!
//! What is measured: the proposals of the group (every document, every kind,
//! drafts aside) that hold at least one vote on their current revision, the
//! last one cast within the past year. For each, T is the number of those
//! modules included) that hold at least one vote on their current revision, the
//! last one cast within the past year — those whose status is named here, and
//! only those: `open` (under vote), `in_force` (in force, still under vote) and
//! `applied` (a removal or a change of inertia that passed: a decision taken).
//! Drafts (nobody votes on them) and proposals withdrawn by their author (their
//! votes can no longer be taken back) stay out, as would any status to come
//! until it is named. For each, T is the number of those
//! votes **at the time of measuring** — the same T the formula sees, since the
//! vote is permanent. The median of these T, and how many proposals it rests
//! on, are kept in `settings` under `participation:<group>`.
@@ -37,16 +42,15 @@ pub struct Measure {
pub measured_at: String,
}
/// The numbers of voters of the group's proposals voted on since `since`.
/// Drafts and withdrawn proposals stay out: neither is shown, and votes on a
/// withdrawn proposal can no longer be taken back.
/// The numbers of voters of the group's proposals voted on since `since`, for
/// the statuses counted only (the list is explicit: see the head of this file).
pub async fn sample(state: &AppState, group: &str, since: &str) -> sqlx::Result<Vec<u32>> {
let rows: Vec<i64> = sqlx::query_scalar(
"SELECT COUNT(*) FROM votes v
JOIN proposals p ON p.id = v.proposal_id
JOIN units u ON u.id = p.unit_id
JOIN documents d ON d.id = u.doc_id
WHERE d.group_slug = ? AND p.status NOT IN ('draft', 'withdrawn') AND v.revision = p.revision
WHERE d.group_slug = ? AND p.status IN ('open', 'in_force', 'applied') AND v.revision = p.revision
GROUP BY v.proposal_id
HAVING MAX(v.cast_at) >= ?",
)
+28 -41
View File
@@ -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);
}
}
+62 -4
View File
@@ -289,13 +289,15 @@ pub async fn status(state: &AppState) -> anyhow::Result<serde_json::Value> {
}))
}
/// Markdown per language + JSON. Only the units in force, in order.
/// Markdown per language + JSON. Only the articles in force, in order; the
/// title of the document is its title in force (ADR 0009).
pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(String, String)>> {
let titles = util::parse_lang(&st.doc.title);
let src = st.doc.source_lang.clone();
let modules = modules_json(state, st).await?;
let stale_title = |lang: &str| modules["title"]["translations"][lang]["stale"].as_bool().unwrap_or(false);
let in_force: Vec<_> = st
.units
.iter()
.articles()
.filter_map(|u| {
let fid = u.in_force_id.as_ref()?;
st.proposals.iter().find(|p| &p.id == fid).map(|p| (u, p))
@@ -313,6 +315,9 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
st.doc.slug,
if lang == src { "fait_foi: oui\n".to_owned() } else { "traduction: editoriale\n".to_owned() },
);
if lang != src && stale_title(lang) {
md.push_str(&format!("\n<!-- {lang} : titre à revoir, le titre source a changé -->\n"));
}
for (_, p) in &in_force {
let t = tr.get(&p.id).and_then(|m| m.get(lang));
let (ut, ub, fallback) = match t {
@@ -356,8 +361,61 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
let json = json!({
"document": st.doc.slug, "group": st.group.slug, "title": titles, "sourceLang": src,
"source": source_url, "updatedAt": updated, "electorate": st.electorate,
"voteFloor": st.doc.vote_floor, "units": units,
"voteFloor": st.doc.vote_floor, "units": units, "modules": modules,
});
files.push(("document.json".into(), format!("{}\n", serde_json::to_string_pretty(&json)?)));
Ok(files)
}
/// The modules of the document as published: the title and the order of each
/// section, each with its level, its state and its votes (ADR 0009).
async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result<serde_json::Value> {
use crate::modules::ModuleKind;
let status = |p: &docs::ProposalRow| {
if p.entered_at.is_some() {
"adopted"
} else if p.confirmed_at.is_some() {
"confirmed"
} else {
"seed-minimal"
}
};
let version = |u: &docs::UnitRow| u.in_force_id.as_ref().and_then(|id| st.proposals.iter().find(|p| &p.id == id));
let title = match st.modules(ModuleKind::Title).first().and_then(|u| Some((*u, version(u)?))) {
Some((u, p)) => {
let tr = docs::translations_for(state, &[p]).await.map_err(|e| anyhow::anyhow!("{e}"))?;
let translations: serde_json::Map<String, serde_json::Value> = tr
.get(&p.id)
.map(|m| {
m.iter().map(|(l, t)| (l.clone(), json!({ "title": t["title"], "stale": t["stale"] }))).collect()
})
.unwrap_or_default();
let tally = st.tally(&p.id);
json!({
"inertia": crate::docs::inertia_of(u), "status": status(p), "enteredAt": p.entered_at,
"votes": { "for": tally.for_votes, "against": tally.against }, "translations": translations,
})
}
None => serde_json::Value::Null,
};
let orders: Vec<_> = st
.modules(ModuleKind::Order)
.into_iter()
.filter_map(|u| {
let p = version(u)?;
let section = u.section.clone().unwrap_or_default();
let codes: Vec<&str> = st
.members(&section)
.iter()
.filter_map(|id| st.units.iter().find(|x| &x.id == id).map(|x| x.code.as_str()))
.collect();
let tally = st.tally(&p.id);
Some(json!({
"section": section, "inertia": crate::docs::inertia_of(u), "status": status(p),
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
"articles": codes,
}))
})
.collect();
Ok(json!({ "title": title, "orders": orders }))
}
+27 -3
View File
@@ -7,10 +7,14 @@
//! existing document is a seed amendment (`seed_amend`): listed, decided once,
//! and applied only to an article nobody has touched; a newer starting level of
//! inertia, an inertia amendment (`inertia_amend`), on an article whose inertia
//! no vote changed.
//! no vote changed; modules the seed declares (the title, the order of each
//! section, ADR 0009), a module amendment (`module_amend`), built from what the
//! document holds.
use crate::AppState;
use crate::inertia_amend;
use crate::module_amend;
use crate::modules;
use crate::seed_amend;
use crate::util::{self, LangMap};
use ld_engine::inertia::InertiaLevel;
@@ -25,9 +29,24 @@ pub struct SeedDoc {
pub summary: LangMap,
pub source_lang: String,
pub instances: Vec<SeedInstance>,
/// The modules of the document (ADR 0009): its title, the order of each section.
#[serde(default)]
pub modules: Option<SeedModules>,
pub units: Vec<SeedUnit>,
}
#[derive(Deserialize)]
pub struct SeedModules {
pub title: Option<SeedModule>,
pub order: Option<SeedModule>,
}
#[derive(Deserialize)]
pub struct SeedModule {
/// The level it starts at, 1 to 5.
pub inertia: InertiaLevel,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeedInstance {
@@ -72,12 +91,13 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
Ok(())
}
/// On start: the groups, the documents missing from them, then the seed and
/// inertia amendments (each decided once per document).
/// On start: the groups, the documents missing from them, then the seed,
/// inertia and module amendments (each decided once per document).
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
let seeds = documents(state).await?;
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
inertia_amend::apply(state, &seeds, inertia_amend::AMENDMENTS).await?;
module_amend::apply(state, &seeds, module_amend::AMENDMENTS).await?;
Ok(())
}
@@ -223,6 +243,10 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
}
}
}
// The modules, from the document just written: its title, the order of each section.
if let Some(m) = &seed.modules {
modules::create(&mut tx, &doc_id, m.into(), &now).await?;
}
if inst.publish_path.is_some() {
sqlx::query(
"INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, 'publication initiale', ?, ?)",
+77 -2
View File
@@ -618,10 +618,10 @@ async fn a_session_sees_its_own_ballots_only() {
assert_eq!((find(&mine, &i1)["myVote"].as_str(), find(&mine, &e1)["myVote"].as_str()), (Some("for"), None));
let theirs = other.doc("monnaie-libre").await;
assert_eq!((find(&theirs, &i1)["myVote"].as_str(), find(&theirs, &e1)["myVote"].as_str()), (None, Some("against")));
// « Décisions » counts the same way.
// « Décisions » counts the same way (33 articles, the title, 7 orders with something to decide).
let d = chiara.ok("GET", "/api/groups/monnaie-libre/decisions", None).await;
assert_eq!(d["canVote"], true);
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(32)));
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(40)));
}
#[tokio::test]
@@ -668,6 +668,81 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back");
}
#[tokio::test]
async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() {
// The modules (ADR 0009) are voted like articles: in a basket, in one transaction.
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let order = doc["modules"]["orders"]
.as_array()
.expect("orders")
.iter()
.find(|o| o["section"] == "conclusion")
.expect("conclusion")
.clone();
let (k1, k2) = (unit(&doc, "K1")["id"].as_str().expect("K1"), unit(&doc, "K2")["id"].as_str().expect("K2"));
assert_eq!(order["members"], json!([k1, k2]));
let o = c
.ok(
"POST",
&format!("/api/units/{}/order", order["id"].as_str().expect("id")),
Some(json!({ "order": [k2, k1] })),
)
.await["id"]
.as_str()
.expect("order")
.to_owned();
let title_unit = doc["modules"]["title"]["id"].as_str().expect("title").to_owned();
let t = c
.ok(
"POST",
&format!("/api/units/{title_unit}/title"),
Some(json!({ "title": "Acte d'engagement de certification" })),
)
.await["id"]
.as_str()
.expect("title")
.to_owned();
let doc = c.doc("monnaie-libre").await;
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
let mut last = Value::Null;
for (n, account) in keys.iter().enumerate() {
let b = c.ballot(&doc, &[(&o, Some("for")), (&t, Some("for"))], None).await;
let index = 20_000 + u32::try_from(n).expect("n");
indexer.pay(
2_000 + index,
b["comment"].as_str().expect("c"),
Signer { account, identity: Some((index, "Membre", true)) },
);
last = b;
if n % 10 == 9 {
ballots::sweep(&state).await.expect("sweep");
}
}
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&last).await;
assert_eq!(s["status"], "confirmed");
let mut entered: Vec<(&str, &str)> = s["entered"]
.as_array()
.expect("entered")
.iter()
.map(|e| (e["code"].as_str().unwrap_or_default(), e["kind"].as_str().unwrap_or_default()))
.collect();
entered.sort_unstable();
assert_eq!(entered, [("ordre.conclusion", "order"), ("titre", "title")], "{s}");
let d = c.doc("monnaie-libre").await;
let (p1, p2) = (unit(&d, "K1")["position"].as_f64().expect("p"), unit(&d, "K2")["position"].as_f64().expect("p"));
assert!(p2 < p1, "K2 now reads before K1");
assert_eq!(d["doc"]["title"]["fr"], "Acte d'engagement de certification");
assert_eq!(d["modules"]["title"]["inForce"]["translations"]["en"]["stale"], true, "the English title, to review");
// The adoptions keep what entered, by kind.
let kinds: Vec<String> =
sqlx::query_scalar("SELECT kind FROM adoptions ORDER BY kind").fetch_all(&state.db).await.expect("k");
assert_eq!(kinds, ["order", "title"]);
}
// ---------------------------------------------------------------------------
// Without the chain, and in managed groups
// ---------------------------------------------------------------------------
+57
View File
@@ -310,10 +310,66 @@ async fn a_withdrawn_proposal_leaves_the_usual_participation() {
assert_eq!((p["source"].as_str(), p["sample"].as_u64()), (Some("reference"), Some(4)));
}
#[tokio::test]
async fn the_usual_participation_counts_the_statuses_it_names_and_only_those() {
// Relecture of 71819f9: the list is explicit — open, in force, applied. A decision
// taken (a change of inertia applied) keeps its voters; a status not named stays out.
let (mut first, state) = Client::new().await;
let mut people = Vec::new();
for name in ["S1", "S2"] {
let mut c = first.other();
c.login(name).await;
people.push(c);
}
first.login("S1").await;
let doc = first.doc("essai").await;
let open: Vec<String> = ["I2", "T1", "E4", "E5", "E6", "E8"]
.iter()
.map(|code| unit(&doc, code)["proposals"][0]["id"].as_str().expect("open").to_owned())
.collect();
for p in &open {
for c in &mut people {
c.vote(p, "against").await;
}
}
let sample = |state: AppState| async move {
ld_server::participation::refresh(&state, true).await.expect("refresh");
ld_server::participation::stored(&state, "essai").await.expect("stored").expect("measure").sample
};
assert_eq!(sample(state.clone()).await, 6);
for (id, status) in [(&open[0], "applied"), (&open[1], "in_force")] {
sqlx::query("UPDATE proposals SET status = ? WHERE id = ?")
.bind(status)
.bind(id)
.execute(&state.db)
.await
.expect("status");
}
assert_eq!(sample(state.clone()).await, 6, "applied and in force stay counted");
sqlx::query("UPDATE proposals SET status = 'superseded' WHERE id = ?")
.bind(&open[2])
.execute(&state.db)
.await
.expect("status");
assert_eq!(sample(state.clone()).await, 5, "a status not named is out");
}
// ---------------------------------------------------------------------------
// The base as production holds it on 2026-10-30, then the new version
// ---------------------------------------------------------------------------
/// The seed as it was then: no modules yet (ADR 0009 came after).
fn seed_of_the_time(tag: &str) -> std::path::PathBuf {
let today = Config::for_tests().seed_dir.join("acte-engagement-certification.json");
let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json");
seed.as_object_mut().expect("object").remove("modules");
let dir = std::env::temp_dir().join(format!("ld-levels-seed-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
dir
}
/// Migrations up to 0004: the schema production runs before this version.
async fn base_before_levels(tag: &str) -> (SqlitePool, AppState) {
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
@@ -325,6 +381,7 @@ async fn base_before_levels(tag: &str) -> (SqlitePool, AppState) {
before.run(&pool).await.expect("migrations 1 to 4");
let mut cfg = Config::for_tests();
cfg.data_dir = std::env::temp_dir().join(format!("ld-levels-{tag}-{}", std::process::id()));
cfg.seed_dir = seed_of_the_time(tag);
let state = AppState::new(cfg, pool.clone()).await.expect("state");
seed::documents(&state).await.expect("documents");
// The words of the time: every article `standard`.
+596
View File
@@ -0,0 +1,596 @@
//! Modules (ADR 0009): the title of the document (level 4) and the order of the
//! articles of each section (level 2), voted like articles; the guarded
//! addition that gives them to a document born without them.
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
use serde_json::{Value, json};
use tower::ServiceExt;
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
fn of(state: &AppState) -> Self {
Self { router: app(state.clone()), cookie: None }
}
async fn new() -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self::of(&state), state)
}
fn other(&self) -> Self {
Self { router: self.router.clone(), cookie: None }
}
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
if let Some(c) = &self.cookie {
req = req.header(header::COOKIE, c);
}
let req = match body {
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
None => req.body(Body::empty()),
}
.expect("request");
let res = self.router.clone().oneshot(req).await.expect("response");
let status = res.status();
if let Some(set) = res.headers().get(header::SET_COOKIE) {
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
if raw.starts_with("ld_session=") {
self.cookie = Some(raw);
}
}
let bytes = res.into_body().collect().await.expect("body").to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
let (s, v) = self.call(method, uri, body).await;
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
v
}
async fn login(&mut self, name: &str) {
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
}
async fn doc(&mut self, group: &str) -> Value {
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
self.ok("GET", &format!("/api/docs/{id}"), None).await
}
async fn vote(&mut self, proposal: &str, choice: &str) -> Value {
self.ok("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await
}
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
fn id_of(doc: &Value, code: &str) -> String {
unit(doc, code)["id"].as_str().expect("id").to_owned()
}
fn order_of<'a>(doc: &'a Value, section: &str) -> &'a Value {
doc["modules"]["orders"].as_array().expect("orders").iter().find(|o| o["section"] == section).expect("order")
}
/// The codes of the articles of a section one can see (a text in force or an open
/// version), in the order the document reads them.
fn read_order(doc: &Value, section: &str) -> Vec<String> {
let seen = |u: &Value| {
!u["inForce"].is_null()
|| u["proposals"]
.as_array()
.is_some_and(|p| p.iter().any(|p| p["status"] == "open" && p["kind"] != "inertia"))
};
let mut units: Vec<&Value> =
doc["units"].as_array().expect("units").iter().filter(|u| u["section"] == section && seen(u)).collect();
units.sort_by(|a, b| a["position"].as_f64().unwrap_or(0.0).total_cmp(&b["position"].as_f64().unwrap_or(0.0)));
units.iter().map(|u| u["code"].as_str().expect("code").to_owned()).collect()
}
fn codes(doc: &Value, ids: &Value) -> Vec<String> {
ids.as_array()
.expect("ids")
.iter()
.map(|id| {
doc["units"]
.as_array()
.expect("units")
.iter()
.find(|u| u["id"] == *id)
.map(|u| u["code"].as_str().expect("code").to_owned())
.unwrap_or_else(|| format!("?{id}"))
})
.collect()
}
/// Two people of the test group (W = 2, C = 1): two « for » adopt.
async fn two() -> (Client, Client, AppState) {
let (mut a, state) = Client::new().await;
a.login("Yvv").await;
let mut b = a.other();
b.login("Chiara").await;
(a, b, state)
}
#[tokio::test]
async fn a_new_base_has_its_title_and_the_order_of_each_section() {
let (mut c, state) = Client::new().await;
c.login("Chiara").await;
for group in ["monnaie-libre", "essai"] {
let doc = c.doc(group).await;
// The articles stay the articles: 33 of them, none of the modules.
assert_eq!(doc["units"].as_array().expect("units").len(), 33, "{group}");
let title = &doc["modules"]["title"];
assert_eq!(
(title["kind"].as_str(), title["code"].as_str(), title["inertia"].as_u64()),
(Some("title"), Some("titre"), Some(4))
);
let v = &title["inForce"];
assert_eq!(
(v["title"].as_str(), v["seedMinimal"].as_bool()),
(Some("Acte d'engagement Certification"), Some(true))
);
assert_eq!(v["translations"]["en"]["title"], "Certification Commitment Act");
assert_eq!(v["translations"]["es"]["stale"], false);
// One order per section, in the order of the document, each at level 2.
let orders = doc["modules"]["orders"].as_array().expect("orders");
let sections: Vec<&str> = orders.iter().filter_map(|o| o["section"].as_str()).collect();
assert_eq!(
sections,
[
"introduction",
"fondamental",
"technique",
"conclusion",
"annexe",
"formule",
"inertie",
"ordonnancement"
]
);
assert!(orders.iter().all(|o| o["inertia"] == 2 && o["inForce"]["seedMinimal"] == true));
// Its version in force is the order the section reads in now.
let fondamental = order_of(&doc, "fondamental");
assert_eq!(codes(&doc, &fondamental["members"]), ["T1", "E1", "E2"]);
assert_eq!(fondamental["inForce"]["order"]["ids"], fondamental["members"]);
assert_eq!(fondamental["moot"], false);
// « Ordonnancement » holds one article: nothing to decide.
assert_eq!(order_of(&doc, "ordonnancement")["moot"], true);
}
// Born with them: the addition says so, once per document.
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT d.group_slug, m.outcome FROM module_amendments m JOIN documents d ON d.id = m.doc_id ORDER BY d.group_slug",
)
.fetch_all(&state.db)
.await
.expect("decisions");
assert_eq!(rows, [("essai".into(), "present".into()), ("monnaie-libre".into(), "present".into())]);
// The previews show articles only.
let doc = c.doc("monnaie-libre").await;
let id = doc["doc"]["id"].as_str().expect("id");
let all = c.ok("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
assert_eq!(all["units"].as_array().expect("units").len(), 33);
// The list of the group counts articles only.
let g = c.ok("GET", "/api/groups/monnaie-libre", None).await;
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(3), Some(32)));
}
#[tokio::test]
async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_force() {
let (mut yvv, mut chiara, _) = two().await;
let doc = yvv.doc("essai").await;
let conclusion = order_of(&doc, "conclusion").clone();
let module = conclusion["id"].as_str().expect("id").to_owned();
let (k1, k2) = (id_of(&doc, "K1"), id_of(&doc, "K2"));
let uri = format!("/api/units/{module}/order");
// Every article of the section, each once, in another order.
for (wrong, code) in [
(json!([k1]), "order"),
(json!([k2, k1, k1]), "order"),
(json!([k2, id_of(&doc, "E1")]), "order"),
(json!([k1, k2]), "same_order"),
] {
let (s, v) = yvv.call("POST", &uri, Some(json!({ "order": wrong }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{wrong}");
}
let first = yvv.ok("POST", &uri, Some(json!({ "order": [k2, k1] }))).await;
assert_eq!(first["existing"], false);
// The same order again: the open one is given back.
let again = chiara.ok("POST", &uri, Some(json!({ "order": [k2, k1] }))).await;
assert_eq!((again["id"].as_str(), again["existing"].as_bool()), (first["id"].as_str(), Some(true)));
let p = first["id"].as_str().expect("id").to_owned();
let doc = yvv.doc("essai").await;
let proposal = &order_of(&doc, "conclusion")["proposals"][0];
assert_eq!((proposal["kind"].as_str(), proposal["votedAtInertia"].as_u64()), (Some("order"), Some(2)));
assert_eq!(codes(&doc, &proposal["order"]["ids"]), ["K2", "K1"]);
assert_eq!(proposal["order"]["same"], false);
// Voted like an article: one voice of two is not enough, the second brings it into force.
assert!(yvv.vote(&p, "for").await["entered"].as_array().expect("e").is_empty());
let v = chiara.vote(&p, "for").await;
assert_eq!(
(v["entered"][0]["code"].as_str(), v["entered"][0]["kind"].as_str()),
(Some("ordre.conclusion"), Some("order"))
);
let doc = yvv.doc("essai").await;
assert_eq!(read_order(&doc, "conclusion"), ["K2", "K1"]);
let now = order_of(&doc, "conclusion");
assert_eq!(now["inForce"]["id"], p.as_str());
assert_eq!(codes(&doc, &now["inForce"]["order"]["ids"]), ["K2", "K1"]);
// The order of before goes back among the proposals, with its votes, and could return.
let back = &now["proposals"][0];
assert_eq!((back["status"].as_str(), back["origin"].as_str()), (Some("open"), Some("seed")));
assert_eq!(codes(&doc, &back["order"]["ids"]), ["K1", "K2"]);
// Nothing else moved: the other sections read as before, the texts are the same.
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2"]);
assert_eq!(read_order(&doc, "technique")[0], "T2");
}
#[tokio::test]
async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() {
let (mut yvv, mut chiara, _) = two().await;
let doc = yvv.doc("essai").await;
let module = order_of(&doc, "inertie")["id"].as_str().expect("id").to_owned();
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (n1, n11) = (id_of(&doc, "N1"), id_of(&doc, "N1.1"));
let p = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [n11, n1] }))).await["id"]
.as_str()
.expect("id")
.to_owned();
yvv.vote(&p, "for").await;
// A new article enters the section: at its end, and at the end of the order under vote.
let created = chiara
.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "section": "inertie", "title": "Inertie des modules", "body": "Le titre du document est à 4.", "submit": true })),
)
.await;
let doc = yvv.doc("essai").await;
let fresh = unit(&doc, "N1")["code"]
.as_str()
.map(|_| ())
.and_then(|()| doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).cloned());
let fresh = fresh.expect("the new article");
let new_code = fresh["code"].as_str().expect("code").to_owned();
assert_eq!(read_order(&doc, "inertie"), ["N1", "N1.1", new_code.as_str()], "a new article goes at the end");
let module_now = order_of(&doc, "inertie");
let open = module_now["proposals"].as_array().expect("p").iter().find(|x| x["id"] == p.as_str()).expect("p");
assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1", new_code.as_str()]);
assert_eq!(codes(&doc, &open["order"]["added"]), [new_code.as_str()]);
// Its vote stays: completing an order never asks the voters again.
assert_eq!((open["tally"]["for"].as_u64(), open["myVote"].as_str()), (Some(1), Some("for")));
// The new article leaves again (withdrawn by its author): dropped, as if never there.
let added = created["id"].as_str().expect("id");
chiara.ok("POST", &format!("/api/proposals/{added}/withdraw"), None).await;
let doc = yvv.doc("essai").await;
let open = order_of(&doc, "inertie")["proposals"]
.as_array()
.expect("p")
.iter()
.find(|x| x["id"] == p.as_str())
.cloned()
.expect("p");
assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1"]);
assert!(open["order"]["added"].as_array().expect("a").is_empty());
// An order proposed with three articles, one of which then leaves: dropped from it.
let created = chiara
.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "section": "inertie", "title": "Autre", "body": "Un article de passage dans la section.", "submit": true })),
)
.await;
let other = created["unitId"].as_str().expect("unit").to_owned();
let three = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [other, n11, n1] }))).await
["id"]
.as_str()
.expect("id")
.to_owned();
chiara.ok("POST", &format!("/api/proposals/{}/withdraw", created["id"].as_str().expect("id")), None).await;
let doc = yvv.doc("essai").await;
let o = order_of(&doc, "inertie")["proposals"]
.as_array()
.expect("p")
.iter()
.find(|x| x["id"] == three.as_str())
.cloned()
.expect("o");
assert_eq!(codes(&doc, &o["order"]["ids"]), ["N1.1", "N1"]);
assert_eq!(o["order"]["dropped"], json!([other]));
// Completed, it reads like the first one: the second voice brings that order in.
let v = chiara.vote(&p, "for").await;
assert_eq!(v["entered"][0]["kind"], "order");
let doc = yvv.doc("essai").await;
assert_eq!(read_order(&doc, "inertie"), ["N1.1", "N1"]);
// Now the order in force: the other one changes nothing more and cannot enter.
let o = order_of(&doc, "inertie")["proposals"]
.as_array()
.expect("p")
.iter()
.find(|x| x["id"] == three.as_str())
.cloned()
.expect("o");
assert_eq!(o["order"]["same"], true);
yvv.vote(&three, "for").await;
assert!(chiara.vote(&three, "for").await["entered"].as_array().expect("e").is_empty());
}
#[tokio::test]
async fn the_generic_writing_stops_at_a_module_and_an_order_is_never_corrected() {
let (mut yvv, _, _) = two().await;
let doc = yvv.doc("essai").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let conclusion = order_of(&doc, "conclusion");
let module = conclusion["id"].as_str().expect("id");
let seed_order = conclusion["inForce"]["id"].as_str().expect("id");
let (s, v) = yvv
.call(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "unitId": module, "title": "Texte", "body": "Un texte sur un module.", "submit": true })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("module")));
let (s, v) = yvv
.call(
"PATCH",
&format!("/api/proposals/{seed_order}"),
Some(json!({ "title": "ordre.conclusion", "body": "x\ny" })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
let (s, v) = yvv
.call(
"PUT",
&format!("/api/proposals/{seed_order}/translations/en"),
Some(json!({ "title": "order", "body": "" })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
// A title and an order each have their own door, and only that one.
let title = doc["modules"]["title"]["id"].as_str().expect("title");
let (s, v) = yvv.call("POST", &format!("/api/units/{title}/order"), Some(json!({ "order": [] }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_an_order")));
let (s, v) = yvv.call("POST", &format!("/api/units/{module}/title"), Some(json!({ "title": "Autre" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_a_title")));
// A module's inertia is voted like an article's.
let i = yvv.ok("POST", &format!("/api/units/{module}/inertia"), Some(json!({ "target": 3 }))).await;
assert_eq!(i["existing"], false);
}
#[tokio::test]
async fn another_title_enters_into_force_and_its_translations_are_to_review() {
let (mut yvv, mut chiara, state) = two().await;
let doc = yvv.doc("essai").await;
let module = doc["modules"]["title"]["id"].as_str().expect("id").to_owned();
let uri = format!("/api/units/{module}/title");
for (wrong, code) in [
(" ", "title"),
("Deux\nlignes", "title"),
("Acte d'engagement Certification", "same_title"),
("Acte\u{200b}d'engagement", "hidden_characters"),
] {
let (s, v) = yvv.call("POST", &uri, Some(json!({ "title": wrong }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{wrong:?}");
}
let new_title = "Acte d'engagement de la certification";
let p = yvv.ok("POST", &uri, Some(json!({ "title": new_title }))).await["id"].as_str().expect("id").to_owned();
let doc = yvv.doc("essai").await;
let proposal = &doc["modules"]["title"]["proposals"][0];
assert_eq!((proposal["kind"].as_str(), proposal["votedAtInertia"].as_u64()), (Some("title"), Some(4)));
yvv.vote(&p, "for").await;
let v = chiara.vote(&p, "for").await;
assert_eq!((v["entered"][0]["code"].as_str(), v["entered"][0]["kind"].as_str()), (Some("titre"), Some("title")));
// The document changes title in French; English and Spanish stay, « à revoir ».
let doc = yvv.doc("essai").await;
assert_eq!(doc["doc"]["title"]["fr"], new_title);
assert_eq!(doc["doc"]["title"]["en"], "Certification Commitment Act");
let tr = &doc["modules"]["title"]["inForce"]["translations"];
assert_eq!((tr["en"]["stale"].as_bool(), tr["es"]["stale"].as_bool()), (Some(true), Some(true)));
let g = yvv.ok("GET", "/api/groups/essai", None).await;
assert_eq!(g["docs"][0]["title"]["fr"], new_title);
let d = yvv.ok("GET", "/api/groups/essai/decisions", None).await;
assert_eq!(d["docs"][0]["title"]["fr"], new_title);
// A member translates it again: the document's English title follows, up to date.
yvv.ok(
"PUT",
&format!("/api/proposals/{p}/translations/en"),
Some(json!({ "title": "Commitment Act of Certification", "body": "" })),
)
.await;
let doc = yvv.doc("essai").await;
assert_eq!(doc["doc"]["title"]["en"], "Commitment Act of Certification");
assert_eq!(doc["modules"]["title"]["inForce"]["translations"]["en"]["stale"], false);
// A typo of the title in force is corrected without a vote, and the document follows.
let fixed = "Acte d’engagement de la certification";
let r = chiara.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": fixed, "body": "" }))).await;
assert_eq!(r["edit"], "typo");
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], fixed);
// The test group never publishes; the title of the publication is covered by `render`.
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE d.group_slug = 'essai'",
)
.fetch_one(&state.db)
.await
.expect("outbox");
assert_eq!(n, 0);
}
#[tokio::test]
async fn a_new_article_goes_at_the_end_of_the_section_asked_for() {
let (mut yvv, _, _) = two().await;
let doc = yvv.doc("essai").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let new = |section: Option<&str>, after: Option<String>| json!({ "section": section, "afterUnitId": after, "title": "Nouveau", "body": "Un nouvel article.", "submit": true });
let a = yvv.ok("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(Some("fondamental"), None))).await;
// An older client names an article: the end of its section, not right after it.
let b = yvv.ok("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(None, Some(id_of(&doc, "I1"))))).await;
let (s, v) = yvv.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(Some("nulle-part"), None))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("section")));
let doc = yvv.doc("essai").await;
let code = |created: &Value| {
doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).expect("unit")["code"]
.as_str()
.expect("code")
.to_owned()
};
let (ca, cb) = (code(&a), code(&b));
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2", ca.as_str()]);
assert_eq!(read_order(&doc, "introduction"), ["I1", "I2", cb.as_str()]);
// It joins the order of its section, at its end.
assert_eq!(codes(&doc, &order_of(&doc, "fondamental")["members"]), ["T1", "E1", "E2", ca.as_str()]);
}
// ---------------------------------------------------------------------------
// A base born without modules, as production holds it, then the new version
// ---------------------------------------------------------------------------
fn seed_without_modules(tag: &str) -> std::path::PathBuf {
let today = Config::for_tests().seed_dir.join("acte-engagement-certification.json");
let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json");
seed.as_object_mut().expect("object").remove("modules");
let dir = std::env::temp_dir().join(format!("ld-modules-seed-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
dir
}
/// What must not move: every unit of the base (place, text in force, section,
/// inertia), every version (status, text, revision) and every vote.
async fn snapshot(
state: &AppState,
) -> (Vec<(String, f64, Option<String>, Option<String>, String)>, Vec<(String, String, String, i64)>, i64) {
let units = sqlx::query_as(
"SELECT id, position, in_force_id, section, inertia FROM units WHERE kind = 'article' ORDER BY id",
)
.fetch_all(&state.db)
.await
.expect("units");
let proposals = sqlx::query_as(
"SELECT p.id, p.status, p.body, p.revision FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE u.kind = 'article' ORDER BY p.id",
)
.fetch_all(&state.db)
.await
.expect("proposals");
let votes = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("votes");
(units, proposals, votes)
}
#[tokio::test]
async fn an_existing_document_receives_its_modules_once_from_what_it_holds() {
// Production before this version: the documents, no module.
let mut cfg = Config::for_tests();
cfg.seed_dir = seed_without_modules("applied");
let db = open_db("sqlite::memory:").await.expect("db");
let before = AppState::new(cfg, db).await.expect("state");
seed::documents(&before).await.expect("documents");
let mut c = Client::of(&before);
c.login("Yvv").await;
let mut chiara = c.other();
chiara.login("Chiara").await;
let doc = c.doc("essai").await;
assert!(doc["modules"]["title"].is_null());
// Life goes on there: votes, an article adopted, another one proposed in « conclusion ».
let e3 = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("e3").to_owned();
c.vote(&e3, "for").await;
chiara.vote(&e3, "for").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
c.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "section": "conclusion", "title": "Rappel", "body": "Un rappel final, proposé avant les modules.", "submit": true })),
)
.await;
let kept = snapshot(&before).await;
let reads = read_order(&c.doc("essai").await, "conclusion");
// The new version starts: each document receives its modules, from what it holds.
let state = AppState::new(Config::for_tests(), before.db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
let rows: Vec<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT d.group_slug, m.outcome, m.reason, m.created FROM module_amendments m JOIN documents d ON d.id = m.doc_id
ORDER BY d.group_slug",
)
.fetch_all(&state.db)
.await
.expect("decisions");
let created = r#"["titre","ordre.introduction","ordre.fondamental","ordre.technique","ordre.conclusion","ordre.annexe","ordre.formule","ordre.inertie","ordre.ordonnancement"]"#;
assert_eq!(
rows,
[
("essai".into(), "applied".into(), None, Some(created.into())),
("monnaie-libre".into(), "applied".into(), None, Some(created.into())),
]
);
// Nothing else moved: articles, versions, votes.
assert_eq!(snapshot(&state).await, kept);
let mut c = Client::of(&state);
c.login("Yvv").await;
let doc = c.doc("essai").await;
assert_eq!(doc["modules"]["title"]["inForce"]["title"], "Acte d'engagement Certification");
assert_eq!(
doc["modules"]["title"]["inForce"]["translations"]["es"]["title"],
"Acta de compromiso de Certificación"
);
// The order of each section in force is the one it read in, the new article included.
let conclusion = order_of(&doc, "conclusion");
assert_eq!(codes(&doc, &conclusion["inForce"]["order"]["ids"]), reads);
assert_eq!(reads.len(), 3);
assert_eq!(conclusion["inForce"]["seedMinimal"], true);
// « Décisions » counts them: 41 + the new article in the test group, E3 adopted there.
let d = c.ok("GET", "/api/groups/essai/decisions", None).await;
assert_eq!(d["docs"][0]["toVote"], 41);
// Republished where the document publishes, never from the test group.
let queued: Vec<(String, String)> = sqlx::query_as(
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.reason LIKE 'modules%'",
)
.fetch_all(&state.db)
.await
.expect("outbox");
assert_eq!(
queued,
[(
"monnaie-libre".to_owned(),
"modules ajoutés : le titre du document, l'ordre des articles de chaque section".to_owned()
)]
);
// What is published: the same text, and the modules in document.json.
let ml = c.doc("monnaie-libre").await;
let st = ld_server::docs::load_state(&state, ml["doc"]["id"].as_str().expect("id")).await.expect("state");
let files = ld_server::publish::render(&state, &st).await.expect("render");
let file = |name: &str| files.iter().find(|(n, _)| n == name).map(|(_, c)| c.clone()).expect("file");
assert!(!file("fr.md").contains("ordre.") && file("fr.md").contains("# Acte d'engagement Certification"));
let json: Value = serde_json::from_str(&file("document.json")).expect("json");
assert_eq!(json["units"].as_array().expect("units").len(), 3, "the articles in force, no module");
assert_eq!(
(json["modules"]["title"]["inertia"].as_u64(), json["modules"]["title"]["status"].as_str()),
(Some(4), Some("seed-minimal"))
);
assert_eq!(json["modules"]["orders"].as_array().expect("orders").len(), 8);
assert_eq!(json["modules"]["orders"][1]["articles"], json!(["T1", "E1", "E2"]));
// Once only: the next start adds nothing.
let again = AppState::new(Config::for_tests(), state.db.clone()).await.expect("state");
seed::ensure(&again).await.expect("seed");
let n: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE kind <> 'article'").fetch_one(&again.db).await.expect("n");
assert_eq!(n, 18, "9 modules in each of the two documents");
let decided: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM module_amendments").fetch_one(&again.db).await.expect("n");
assert_eq!(decided, 2);
}
+12 -11
View File
@@ -147,20 +147,21 @@ async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() {
assert_eq!(docs.len(), 1);
assert_eq!(docs[0]["slug"], "acte-engagement-certification");
assert_eq!(docs[0]["title"]["fr"], "Acte d'engagement Certification");
// 3 seeds a minima + 30 open articles, the two variants counting once.
assert_eq!(docs[0]["toVote"], 33);
assert_eq!(docs[0]["withoutMyVote"], 33);
// 3 seeds a minima + 30 open articles, the two variants counting once; the title of the
// document and the order of 7 sections (« ordonnancement » holds one article: nothing to decide).
assert_eq!(docs[0]["toVote"], 41);
assert_eq!(docs[0]["withoutMyVote"], 41);
let doc = the_doc(&mut c, "essai").await;
c.vote(&version(&doc, "E3", "0"), "for").await;
c.vote(&version(&doc, "E3", "1"), "against").await;
c.vote(&version(&doc, "I1", "inForce"), "for").await;
let d = decisions(&mut c, "essai").await;
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(31)));
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(39)));
// Someone else has not voted yet.
let mut yvv = c.other();
yvv.login("Yvv", false).await;
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33);
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
}
#[tokio::test]
@@ -176,7 +177,7 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
let v = chiara.vote(&e3, "for").await;
assert_eq!(v["entered"][0]["code"], "E3");
let d = decisions(&mut yvv, "essai").await;
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(32), Some(32)));
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40)));
// A new article by Chiara: one more module to vote.
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
@@ -191,9 +192,9 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
.await;
assert_eq!(s, StatusCode::OK, "{v}");
let added = v["id"].as_str().expect("id").to_owned();
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 33);
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 41);
yvv.vote(&added, "against").await;
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 32);
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 40);
// Chiara changes its meaning: Yvv's vote is to reconfirm, it no longer counts as his vote.
let (s, v) = chiara
.call(
@@ -206,7 +207,7 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
.await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["edit"], "substantive");
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33);
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
}
#[tokio::test]
@@ -217,10 +218,10 @@ async fn where_one_does_not_vote_there_is_no_count_of_ones_own() {
guest.invited(&mut admin, &["monnaie-libre", "essai"]).await;
let d = decisions(&mut guest, "monnaie-libre").await;
assert_eq!(d["canVote"], false);
assert_eq!(d["docs"][0]["toVote"], 33);
assert_eq!(d["docs"][0]["toVote"], 41);
assert!(d["docs"][0]["withoutMyVote"].is_null());
// In the test group, the same person votes.
let d = decisions(&mut guest, "essai").await;
assert_eq!(d["canVote"], true);
assert_eq!(d["docs"][0]["withoutMyVote"], 33);
assert_eq!(d["docs"][0]["withoutMyVote"], 41);
}
+9 -1
View File
@@ -1,5 +1,5 @@
{
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30).",
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2 (revue du 2026-10-30, ADR 0009).",
"slug": "acte-engagement-certification",
"title": {
"fr": "Acte d'engagement Certification",
@@ -24,6 +24,14 @@
"publishPath": null
}
],
"modules": {
"title": {
"inertia": 4
},
"order": {
"inertia": 2
}
},
"units": [
{
"code": "I1",