Skip to main content

river_data_core/models/
annotations.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// One source-authored annotation to register. `source_key` identifies the
6/// annotation within the source system; registration is idempotent per
7/// (source_system, source_key), so a full-content pass re-asserting the same
8/// key updates in place rather than duplicating.
9///
10/// The API resolves the site and parameter from the stream's pairing; an
11/// annotation on an unpaired stream is reported back as `unpaired` and is
12/// re-asserted on a later cycle once the stream is paired.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15pub struct AnnotationUpsert {
16    pub source_key: String,
17    pub stream_id: Uuid,
18    /// The instant the annotation covers; the API stores it as a point
19    /// annotation (start_time == end_time).
20    pub time: DateTime<Utc>,
21    pub category: String,
22    pub text: String,
23    /// The standard curve the source applied to produce the annotated value. The API freezes an
24    /// annotation's curve and text once stored with one, reporting later edits as `frozen`.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub standard_curve_id: Option<Uuid>,
27}
28
29/// The API-side outcome for one registered annotation.
30#[derive(Debug, Clone, Deserialize)]
31pub struct AnnotationMapping {
32    pub source_key: String,
33    /// None when the annotation could not be stored (`unpaired`).
34    pub id: Option<Uuid>,
35    /// created | updated | unchanged | frozen | unpaired
36    pub status: String,
37}
38
39/// One source-authored site note to register. `source_key` identifies the note
40/// within the source system; registration is idempotent per
41/// (source_system, source_key).
42///
43/// `site_name` is the source's own station name, which the API resolves against
44/// sites that already exist. A note mints nothing: one naming a station
45/// river-data has never seen is reported `unresolved` and is re-asserted on a
46/// later cycle, once pairing has created the site.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
49pub struct NoteUpsert {
50    pub source_key: String,
51    pub site_name: String,
52    pub text: String,
53    pub verified: bool,
54}
55
56/// The API-side outcome for one registered note.
57#[derive(Debug, Clone, Deserialize)]
58pub struct NoteMapping {
59    pub source_key: String,
60    /// None when the note could not be stored (`unresolved`).
61    pub id: Option<Uuid>,
62    /// created | updated | unchanged | unresolved
63    pub status: String,
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    /// The API receives these, so what the client serializes must read back as the same value.
71    #[test]
72    fn annotation_upsert_round_trips() {
73        let up = AnnotationUpsert {
74            source_key: "corrections:12".into(),
75            stream_id: Uuid::nil(),
76            time: Utc::now(),
77            category: "audit".into(),
78            text: "corrected with the January curve".into(),
79            standard_curve_id: Some(Uuid::nil()),
80        };
81        let json = serde_json::to_value(&up).unwrap();
82        let back: AnnotationUpsert = serde_json::from_value(json).unwrap();
83        assert_eq!(back.source_key, up.source_key);
84        assert_eq!(back.standard_curve_id, up.standard_curve_id);
85    }
86
87    #[test]
88    fn an_annotation_without_a_curve_round_trips() {
89        let up = AnnotationUpsert {
90            source_key: "corrections:13".into(),
91            stream_id: Uuid::nil(),
92            time: Utc::now(),
93            category: "audit".into(),
94            text: "no curve".into(),
95            standard_curve_id: None,
96        };
97        let json = serde_json::to_value(&up).unwrap();
98        assert!(json.get("standard_curve_id").is_none());
99        let back: AnnotationUpsert = serde_json::from_value(json).unwrap();
100        assert!(back.standard_curve_id.is_none());
101    }
102
103    #[test]
104    fn note_upsert_round_trips() {
105        let up = NoteUpsert {
106            source_key: "notes:4".into(),
107            site_name: "FP1".into(),
108            text: "gauge replaced".into(),
109            verified: true,
110        };
111        let back: NoteUpsert = serde_json::from_value(serde_json::to_value(&up).unwrap()).unwrap();
112        assert_eq!(back.site_name, "FP1");
113        assert!(back.verified);
114    }
115}