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