forked from yvv/decision
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>
167 lines
6.6 KiB
Rust
167 lines
6.6 KiB
Rust
//! The order of the articles of a section, a module (ADR 0009) — the rules are
|
|
//! the engine's (`ld_engine::order`); here, the database and the door to
|
|
//! propose one.
|
|
//!
|
|
//! - The version in force is the order the section reads in now: its articles
|
|
//! one can see, by position. The text the engine compares for it is that live
|
|
//! order, so it always reads what the reader shows.
|
|
//! - A proposal is another order of the same articles, each once. While it is
|
|
//! under vote, articles may enter the section or leave it: it is completed at
|
|
//! the end (`ld_engine::order::fit`), and its votes stay.
|
|
//! - Entering into force gives the section's places out again in the new order;
|
|
//! the articles keep their texts, their votes, their sections.
|
|
|
|
use crate::AppState;
|
|
use crate::auth::User;
|
|
use crate::docs::{self, DocState, ProposalRow, UnitRow};
|
|
use crate::error::{AppError, AppResult};
|
|
use crate::modules::{self, ModuleKind, NewVersion};
|
|
use crate::util;
|
|
use axum::Json;
|
|
use axum::extract::{Path, State};
|
|
use ld_engine::order::{self, Article, OrderError};
|
|
use serde::Deserialize;
|
|
use serde_json::{Value, json};
|
|
use sqlx::SqliteConnection;
|
|
|
|
/// More articles than any section will hold: a longer list is refused unread.
|
|
const MAX_ARTICLES: usize = 500;
|
|
|
|
/// Every section of a document, in the order of the document, with the articles
|
|
/// of its order now (read inside a transaction: the seed writes them in it).
|
|
pub async fn sections_now(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<Vec<(String, Vec<String>)>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row {
|
|
id: String,
|
|
section: Option<String>,
|
|
position: f64,
|
|
visible: bool,
|
|
}
|
|
// Seen: a text in force, or an open version (`DocState::members` says the same).
|
|
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 })))
|
|
}
|