forked from yvv/decision
241 lines
9.9 KiB
Rust
241 lines
9.9 KiB
Rust
//! 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 = crate::ballots::access(&state, &user, &group).await?["open"].as_bool().unwrap_or(false);
|
|
#[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 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; a version held
|
|
/// by an objection is not counted as one to vote while it is held: it takes no
|
|
/// new *for*, only against or a withdrawal (ADR 0010).
|
|
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct VersionRow {
|
|
id: String,
|
|
unit_id: String,
|
|
in_force: bool,
|
|
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 p.id AS id, 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.revision = p.revision
|
|
AND (v.identity_id = ?1 OR v.ballot_id IN
|
|
(SELECT b.id FROM ballots b WHERE b.requested_by = ?1 AND b.status = 'confirmed')))
|
|
-- An intention still waiting for the chain is one's vote already (ADR 0012).
|
|
OR EXISTS (SELECT 1 FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id
|
|
WHERE b.requested_by = ?1 AND b.status = 'pending' AND b.mode = 'chain'
|
|
AND l.proposal_id = p.id AND l.revision = p.revision AND l.choice <> 'none')) AS voted
|
|
FROM units u JOIN proposals p ON p.unit_id = u.id
|
|
WHERE u.doc_id = ?2 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 { moot: moot.contains(&r.unit_id), ..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 if st.proposals.iter().any(|p| p.id == r.id && st.votable(p)) {
|
|
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);
|
|
}
|
|
}
|