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:
+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
|
||||
|
||||
Reference in New Issue
Block a user