forked from yvv/decision
Fusion du lot A : accueil sur le choix du groupe, logo Clé de voûte, quatre ambiances, menu, À venir, Décisions
Contrôles du lot R renumérotés C18 (Raison d'être) et C19 (versions d'un article). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -237,7 +237,7 @@ fn safe_return(raw: Option<&str>) -> String {
|
||||
{
|
||||
p.to_owned()
|
||||
}
|
||||
_ => "/groupes".to_owned(),
|
||||
_ => "/".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,12 +577,12 @@ mod tests {
|
||||
#[test]
|
||||
fn return_paths_stay_local() {
|
||||
assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre");
|
||||
assert_eq!(safe_return(Some("//evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(Some("https://evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(Some("/\\evil")), "/groupes");
|
||||
assert_eq!(safe_return(Some("/\t/evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(Some("/\n/evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(None), "/groupes");
|
||||
assert_eq!(safe_return(Some("//evil.example")), "/");
|
||||
assert_eq!(safe_return(Some("https://evil.example")), "/");
|
||||
assert_eq!(safe_return(Some("/\\evil")), "/");
|
||||
assert_eq!(safe_return(Some("/\t/evil.example")), "/");
|
||||
assert_eq!(safe_return(Some("/\n/evil.example")), "/");
|
||||
assert_eq!(safe_return(None), "/");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod auth;
|
||||
pub mod config;
|
||||
pub mod docs;
|
||||
pub mod error;
|
||||
pub mod overview;
|
||||
pub mod proposals;
|
||||
pub mod publish;
|
||||
pub mod seed;
|
||||
@@ -42,6 +43,8 @@ pub struct AppState {
|
||||
pub http: reqwest::Client,
|
||||
/// Wakes the publisher as soon as an adoption is recorded.
|
||||
pub publish_wake: Arc<Notify>,
|
||||
/// What is coming in the tools of each group (`seeds/upcoming/`), read at start.
|
||||
pub upcoming: Arc<overview::Upcoming>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -50,7 +53,8 @@ impl AppState {
|
||||
.timeout(Duration::from_secs(15))
|
||||
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()) })
|
||||
let upcoming = Arc::new(overview::Upcoming::load(&cfg.seed_dir).await?);
|
||||
Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()), upcoming })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +108,8 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/me", get(auth::me))
|
||||
.route("/groups", get(docs::groups))
|
||||
.route("/groups/{slug}", get(docs::group))
|
||||
.route("/groups/{slug}", get(overview::group))
|
||||
.route("/groups/{slug}/decisions", get(overview::decisions))
|
||||
.route("/docs/{id}", get(docs::document))
|
||||
.route("/docs/{id}/preview", get(docs::preview))
|
||||
.route("/docs/{id}/assist", post(proposals::assist))
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! A group at a glance: what is coming in each tool, and what is left to vote.
|
||||
//!
|
||||
//! What is coming is data, not code: `seeds/upcoming/<group>.json`, one file
|
||||
//! per group, read once at start (titles in fr, en and es). It is served with
|
||||
//! the group (`GET /api/groups/{slug}`, field `upcoming`), only to those who
|
||||
//! open it. What is left to vote (`GET /api/groups/{slug}/decisions`) is one
|
||||
//! line per document, counted by `ld_engine::pending`.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::User;
|
||||
use crate::docs;
|
||||
use crate::error::AppResult;
|
||||
use crate::util::{self, LangMap};
|
||||
use anyhow::{Context, bail};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::pending::{Module, pending};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
/// Something announced in a tool of a group, not open yet.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpcomingItem {
|
||||
pub key: String,
|
||||
pub title: LangMap,
|
||||
}
|
||||
|
||||
/// What is coming in the tools of one group.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GroupUpcoming {
|
||||
#[serde(default)]
|
||||
pub docs: Vec<UpcomingItem>,
|
||||
#[serde(default)]
|
||||
pub mandats: Vec<UpcomingItem>,
|
||||
#[serde(default)]
|
||||
pub protocoles: Vec<UpcomingItem>,
|
||||
}
|
||||
|
||||
impl GroupUpcoming {
|
||||
fn check(&self) -> anyhow::Result<()> {
|
||||
for (tool, items) in [("docs", &self.docs), ("mandats", &self.mandats), ("protocoles", &self.protocoles)] {
|
||||
let mut keys = HashSet::new();
|
||||
for item in items {
|
||||
if item.key.trim().is_empty() || !keys.insert(item.key.as_str()) {
|
||||
bail!("{tool}: empty or repeated key « {} »", item.key);
|
||||
}
|
||||
for lang in util::LANGS {
|
||||
if item.title.get(lang).is_none_or(|t| t.trim().is_empty()) {
|
||||
bail!("{tool}/{}: no title in {lang}", item.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Every group's announcements, by group slug.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Upcoming(BTreeMap<String, GroupUpcoming>);
|
||||
|
||||
impl Upcoming {
|
||||
/// Reads `<seed_dir>/upcoming/*.json`; no directory, nothing coming. A file
|
||||
/// that does not read, or lacks a language, stops the start.
|
||||
pub async fn load(seed_dir: &std::path::Path) -> anyhow::Result<Self> {
|
||||
let dir = seed_dir.join("upcoming");
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
|
||||
Err(e) => return Err(e).context(dir.display().to_string()),
|
||||
};
|
||||
let mut out = BTreeMap::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|x| x != "json") {
|
||||
continue;
|
||||
}
|
||||
let Some(group) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
|
||||
let raw = tokio::fs::read_to_string(&path).await?;
|
||||
let parsed: GroupUpcoming = serde_json::from_str(&raw).with_context(|| path.display().to_string())?;
|
||||
parsed.check().with_context(|| path.display().to_string())?;
|
||||
out.insert(group, parsed);
|
||||
}
|
||||
Ok(Self(out))
|
||||
}
|
||||
|
||||
pub fn of(&self, group: &str) -> GroupUpcoming {
|
||||
self.0.get(group).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The group, its tools and documents (`docs::group`), plus what is coming.
|
||||
pub async fn group(State(state): State<AppState>, user: User, Path(slug): Path<String>) -> AppResult<Json<Value>> {
|
||||
let Json(mut view) = docs::group(State(state.clone()), user, Path(slug.clone())).await?;
|
||||
let open = view["group"]["open"].as_bool().unwrap_or(false);
|
||||
let upcoming = if open { state.upcoming.of(&slug) } else { GroupUpcoming::default() };
|
||||
view["upcoming"] = serde_json::to_value(upcoming).unwrap_or(Value::Null);
|
||||
Ok(Json(view))
|
||||
}
|
||||
|
||||
/// « Décisions »: one line per document in progress, with its modules left to vote.
|
||||
pub async fn decisions(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(slug): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let group = docs::load_group(&state, &slug).await?;
|
||||
docs::require_open(&state, &user, &group).await?;
|
||||
let can_vote = docs::may_vote(&state, &user, &group).await?;
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DocRow {
|
||||
id: String,
|
||||
slug: String,
|
||||
title: String,
|
||||
}
|
||||
let rows =
|
||||
sqlx::query_as::<_, DocRow>("SELECT id, slug, title FROM documents WHERE group_slug = ? ORDER BY created_at")
|
||||
.bind(&slug)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for d in rows {
|
||||
let p = pending(&modules(&state, &d.id, &user.id).await?);
|
||||
out.push(json!({
|
||||
"id": d.id, "slug": d.slug, "title": util::parse_lang(&d.title),
|
||||
"toVote": p.to_vote,
|
||||
"withoutMyVote": can_vote.then_some(p.without_my_vote),
|
||||
}));
|
||||
}
|
||||
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.
|
||||
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct VersionRow {
|
||||
unit_id: String,
|
||||
in_force: bool,
|
||||
adopted: bool,
|
||||
voted: bool,
|
||||
}
|
||||
let rows = sqlx::query_as::<_, VersionRow>(
|
||||
"SELECT u.id AS unit_id,
|
||||
IFNULL(p.id = u.in_force_id, 0) AS in_force,
|
||||
(p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted,
|
||||
EXISTS (SELECT 1 FROM votes v
|
||||
WHERE v.proposal_id = p.id AND v.identity_id = ? AND v.revision = p.revision) AS voted
|
||||
FROM units u JOIN proposals p ON p.unit_id = u.id
|
||||
WHERE u.doc_id = ? AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open')
|
||||
ORDER BY u.position, u.id",
|
||||
)
|
||||
.bind(me)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out: Vec<Module> = Vec::new();
|
||||
let mut current: Option<String> = None;
|
||||
for r in rows {
|
||||
if current.as_deref() != Some(r.unit_id.as_str()) {
|
||||
out.push(Module::default());
|
||||
current = Some(r.unit_id);
|
||||
}
|
||||
if let Some(m) = out.last_mut() {
|
||||
if r.in_force {
|
||||
m.in_force = Some(r.adopted);
|
||||
} else {
|
||||
m.open += 1;
|
||||
}
|
||||
m.voted |= r.voted;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Config;
|
||||
|
||||
async fn load(files: &[(&str, &str)]) -> anyhow::Result<Upcoming> {
|
||||
let dir = std::env::temp_dir().join(format!("ld-upcoming-{}", util::id()));
|
||||
tokio::fs::create_dir_all(dir.join("upcoming")).await?;
|
||||
for (name, body) in files {
|
||||
tokio::fs::write(dir.join("upcoming").join(name), body).await?;
|
||||
}
|
||||
let out = Upcoming::load(&dir).await;
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
out
|
||||
}
|
||||
|
||||
const ITEM: &str = r#"{"key": "k", "title": {"fr": "Un", "en": "One", "es": "Uno"}}"#;
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_file_per_group_and_nothing_elsewhere() {
|
||||
let up =
|
||||
load(&[("axiom.json", &format!(r#"{{"mandats": [{ITEM}]}}"#)), ("notes.txt", "x")]).await.expect("load");
|
||||
assert_eq!(up.of("axiom").mandats.len(), 1);
|
||||
assert!(up.of("axiom").docs.is_empty());
|
||||
assert!(up.of("librodrome").mandats.is_empty());
|
||||
let none = Upcoming::load(&std::env::temp_dir().join("ld-no-such-dir")).await.expect("load");
|
||||
assert!(none.of("axiom").docs.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_language_an_unknown_tool_or_a_repeated_key_stops_the_start() {
|
||||
let no_es = r#"{"docs": [{"key": "k", "title": {"fr": "Un", "en": "One"}}]}"#;
|
||||
assert!(load(&[("axiom.json", no_es)]).await.is_err());
|
||||
assert!(load(&[("axiom.json", &format!(r#"{{"outils": [{ITEM}]}}"#))]).await.is_err());
|
||||
assert!(load(&[("axiom.json", &format!(r#"{{"docs": [{ITEM}, {ITEM}]}}"#))]).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_seeds_of_the_repository_read() {
|
||||
let up = Upcoming::load(&Config::for_tests().seed_dir).await.expect("load");
|
||||
assert_eq!(up.of("monnaie-libre").mandats.len(), 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user