Skip to main content

teamcraft/services/
result_templates.rs

1//! Extraction templates of a project group: where a simulation's key numbers
2//! are found in small text files at check-in (see [`crate::resultscan`]).
3//!
4//! Anybody who may see the group reads its templates; the managers of the
5//! group and administrators change them. Every change is audited in the
6//! transaction that makes it.
7
8use serde_json::json;
9use sqlx::{PgConnection, PgPool};
10use uuid::Uuid;
11
12use crate::{
13    auth::Actor,
14    error::{AppError, AppResult},
15    resultscan::{ResultTemplate, TemplateInput, MAX_TEMPLATES},
16    services::{access, audit, projects},
17};
18
19const SELECT: &str = "
20    SELECT id, project_id, name, file_pattern, method, pattern, csv_column, csv_key_column,
21           csv_key_value, pick, unit, lower_limit, upper_limit, load_case, solver, enabled,
22           created_at, updated_at
23    FROM result_templates";
24
25fn not_found() -> AppError {
26    AppError::not_found("Extraction template not found.")
27}
28
29/// The templates of a group the actor may see, by name.
30pub async fn list(
31    pool: &PgPool,
32    actor: &Actor,
33    project_id: Uuid,
34) -> AppResult<Vec<ResultTemplate>> {
35    access::ensure_visible(actor, Some(project_id), access::not_found_project())?;
36    Ok(sqlx::query_as::<_, ResultTemplate>(&format!(
37        "{SELECT} WHERE project_id = $1 ORDER BY lower(name), lower(load_case)"
38    ))
39    .bind(project_id)
40    .fetch_all(pool)
41    .await?)
42}
43
44/// One template, if the actor may see its group.
45pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ResultTemplate> {
46    let t = sqlx::query_as::<_, ResultTemplate>(&format!("{SELECT} WHERE id = $1"))
47        .bind(id)
48        .fetch_optional(pool)
49        .await?
50        .ok_or_else(not_found)?;
51    access::ensure_visible(actor, Some(t.project_id), not_found())?;
52    Ok(t)
53}
54
55/// The enabled templates applied at check-in (inside the caller's
56/// transaction; the caller has checked access to the revision).
57pub(crate) async fn enabled_for(
58    conn: &mut PgConnection,
59    project_id: Uuid,
60) -> AppResult<Vec<ResultTemplate>> {
61    Ok(sqlx::query_as::<_, ResultTemplate>(&format!(
62        "{SELECT} WHERE project_id = $1 AND enabled ORDER BY lower(name), lower(load_case)"
63    ))
64    .bind(project_id)
65    .fetch_all(&mut *conn)
66    .await?)
67}
68
69fn describe(t: &TemplateInput) -> String {
70    format!("{} ({}, {})", t.name, t.method, t.file_pattern)
71}
72
73fn unique(e: sqlx::Error, input: &TemplateInput) -> AppError {
74    AppError::unique_violation(
75        e,
76        format!(
77            "This project group already has a template for '{}'{}.",
78            input.name,
79            if input.load_case.is_empty() {
80                String::new()
81            } else {
82                format!(" (load case '{}')", input.load_case)
83            }
84        ),
85    )
86}
87
88/// Creates a template (managers of the group, administrators).
89///
90/// # Invariants
91///
92/// * **Permission:** manager of the project group or administrator (403); a
93///   hidden group is a 404.
94/// * **Locks:** the project group row (`FOR UPDATE`), so the template count
95///   and the insert are one step.
96/// * **Data:** at most `MAX_TEMPLATES` per group (400); the same name and load
97///   case twice gets a 409.
98/// * **Audit:** `result_template.create`.
99/// * **Notifications / webhooks:** none.
100/// * **Atomic:** one transaction.
101pub async fn create(
102    pool: &PgPool,
103    actor: &Actor,
104    project_id: Uuid,
105    input: TemplateInput,
106) -> AppResult<ResultTemplate> {
107    // a hidden group answers like a missing one, before anything about the input
108    access::ensure_visible(actor, Some(project_id), access::not_found_project())?;
109    let input = input.validated().map_err(AppError::BadRequest)?;
110    let mut tx = pool.begin().await?;
111    // lock the group row: the template count and the insert are one step
112    let code =
113        sqlx::query_scalar::<_, String>("SELECT code FROM projects WHERE id = $1 FOR UPDATE")
114            .bind(project_id)
115            .fetch_optional(&mut *tx)
116            .await?
117            .ok_or_else(|| AppError::not_found("Project not found."))?;
118    projects::require_manager(&mut tx, actor, project_id).await?;
119    let count: i64 =
120        sqlx::query_scalar("SELECT COUNT(*) FROM result_templates WHERE project_id = $1")
121            .bind(project_id)
122            .fetch_one(&mut *tx)
123            .await?;
124    if count as usize >= MAX_TEMPLATES {
125        return Err(AppError::BadRequest(format!(
126            "A project group can have at most {MAX_TEMPLATES} extraction templates."
127        )));
128    }
129    let id = Uuid::new_v4();
130    sqlx::query(
131        "INSERT INTO result_templates (id, project_id, name, file_pattern, method, pattern,
132                csv_column, csv_key_column, csv_key_value, pick, unit, lower_limit, upper_limit,
133                load_case, solver, enabled, created_by)
134         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
135    )
136    .bind(id)
137    .bind(project_id)
138    .bind(&input.name)
139    .bind(&input.file_pattern)
140    .bind(&input.method)
141    .bind(&input.pattern)
142    .bind(&input.csv_column)
143    .bind(&input.csv_key_column)
144    .bind(&input.csv_key_value)
145    .bind(&input.pick)
146    .bind(&input.unit)
147    .bind(input.lower_limit)
148    .bind(input.upper_limit)
149    .bind(&input.load_case)
150    .bind(&input.solver)
151    .bind(input.enabled)
152    .bind(actor.id)
153    .execute(&mut *tx)
154    .await
155    .map_err(|e| unique(e, &input))?;
156    audit::record(
157        &mut tx,
158        Some(actor),
159        "result_template.create",
160        "project",
161        Some(project_id),
162        format!("Extraction template {} in project {code}", describe(&input)),
163        json!({ "template_id": id, "name": input.name, "file_pattern": input.file_pattern,
164                "method": input.method }),
165    )
166    .await?;
167    tx.commit().await?;
168    get(pool, actor, id).await
169}
170
171/// Replaces a template's settings.
172///
173/// # Invariants
174///
175/// * **Permission:** manager of the project group or administrator (403); a
176///   hidden template is a 404.
177/// * **Locks:** none beyond the row lock of the `UPDATE`; the same name and
178///   load case as another template gets a 409.
179/// * **Audit:** `result_template.update`.
180/// * **Notifications / webhooks:** none.
181/// * **Atomic:** one transaction.
182pub async fn update(
183    pool: &PgPool,
184    actor: &Actor,
185    id: Uuid,
186    input: TemplateInput,
187) -> AppResult<ResultTemplate> {
188    let existing = get(pool, actor, id).await?;
189    let input = input.validated().map_err(AppError::BadRequest)?;
190    let mut tx = pool.begin().await?;
191    projects::require_manager(&mut tx, actor, existing.project_id).await?;
192    let updated = sqlx::query(
193        "UPDATE result_templates SET name = $2, file_pattern = $3, method = $4, pattern = $5,
194                csv_column = $6, csv_key_column = $7, csv_key_value = $8, pick = $9, unit = $10,
195                lower_limit = $11, upper_limit = $12, load_case = $13, solver = $14,
196                enabled = $15, updated_at = now()
197         WHERE id = $1",
198    )
199    .bind(id)
200    .bind(&input.name)
201    .bind(&input.file_pattern)
202    .bind(&input.method)
203    .bind(&input.pattern)
204    .bind(&input.csv_column)
205    .bind(&input.csv_key_column)
206    .bind(&input.csv_key_value)
207    .bind(&input.pick)
208    .bind(&input.unit)
209    .bind(input.lower_limit)
210    .bind(input.upper_limit)
211    .bind(&input.load_case)
212    .bind(&input.solver)
213    .bind(input.enabled)
214    .execute(&mut *tx)
215    .await
216    .map_err(|e| unique(e, &input))?;
217    if updated.rows_affected() == 0 {
218        return Err(not_found());
219    }
220    audit::record(
221        &mut tx,
222        Some(actor),
223        "result_template.update",
224        "project",
225        Some(existing.project_id),
226        format!("Extraction template changed: {}", describe(&input)),
227        json!({ "template_id": id, "name": input.name, "file_pattern": input.file_pattern,
228                "method": input.method, "enabled": input.enabled }),
229    )
230    .await?;
231    tx.commit().await?;
232    get(pool, actor, id).await
233}
234
235/// Removes a template; values it extracted before stay.
236///
237/// # Invariants
238///
239/// * **Permission:** manager of the project group or administrator (403); a
240///   hidden template is a 404.
241/// * **Locks:** none beyond the row lock of the `DELETE`.
242/// * **Audit:** `result_template.delete`.
243/// * **Notifications / webhooks:** none.
244/// * **Atomic:** one transaction.
245pub async fn delete(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ResultTemplate> {
246    let existing = get(pool, actor, id).await?;
247    let mut tx = pool.begin().await?;
248    projects::require_manager(&mut tx, actor, existing.project_id).await?;
249    sqlx::query("DELETE FROM result_templates WHERE id = $1")
250        .bind(id)
251        .execute(&mut *tx)
252        .await?;
253    audit::record(
254        &mut tx,
255        Some(actor),
256        "result_template.delete",
257        "project",
258        Some(existing.project_id),
259        format!("Extraction template removed: {}", existing.name),
260        json!({ "template_id": id, "name": existing.name }),
261    )
262    .await?;
263    tx.commit().await?;
264    Ok(existing)
265}