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