forked from yvv/decision
v3 : modules d'ordre et de titre — moteur, données, serveur (L11)
L'ordre des articles de chaque section (cran 2) et le titre du document (cran 4) deviennent des modules : des unités à part, dont les versions sont des propositions votées comme les articles (dépliées, panier, chaîne). - moteur : ld-engine/src/order.rs (qui est dans l'ordre, ordre proposé, complété à la fin quand un article entre ou sort, places redistribuées, nouvel article en fin de section) ; pending : un ordre de moins de deux articles n'est jamais à voter ; - données : migration 0007 (units.kind, module_amendments) ; le seed déclare ses modules ; module_amend.rs les ajoute une fois, gardé et journalisé, aux documents nés sans eux, sans toucher articles ni votes ; - serveur : modules.rs, order.rs, doc_title.rs (portes pour proposer, entrée en vigueur : les articles changent de place, le document change de titre, en et es passent « à revoir ») ; publication des modules ; - reste mineur : la médiane compte les statuts open, in_force, applied, et eux seuls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(§ion);
|
||||
let unit = insert_unit(conn, doc_id, ModuleKind::Order, &code, Some(§ion), 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(()) }
|
||||
}
|
||||
@@ -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, ¤t) {
|
||||
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 })))
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) >= ?",
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(§ion)
|
||||
.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 }))
|
||||
}
|
||||
|
||||
@@ -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', ?, ?)",
|
||||
|
||||
Reference in New Issue
Block a user