Skip to main content

uls_db/
models.rs

1//! High-level domain models for ULS data.
2//!
3//! These models aggregate data from multiple record types into
4//! user-friendly structures for queries and display.
5
6use chrono::NaiveDate;
7use serde::{Deserialize, Serialize};
8
9/// A complete license with all related information.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct License {
12    /// Unique system identifier.
13    pub unique_system_identifier: i64,
14    /// Call sign.
15    pub call_sign: String,
16    /// Licensee/entity name.
17    pub licensee_name: String,
18    /// First name (if individual).
19    pub first_name: Option<String>,
20    /// Middle initial.
21    pub middle_initial: Option<String>,
22    /// Last name (if individual).
23    pub last_name: Option<String>,
24    /// License status (A=Active, C=Cancelled, E=Expired, etc.).
25    pub status: char,
26    /// Radio service code (HA, HV, ZA, etc.).
27    pub radio_service: String,
28    /// Grant date.
29    pub grant_date: Option<NaiveDate>,
30    /// Expiration date.
31    pub expired_date: Option<NaiveDate>,
32    /// Cancellation date.
33    pub cancellation_date: Option<NaiveDate>,
34    /// FRN (FCC Registration Number).
35    pub frn: Option<String>,
36    /// Street address.
37    pub street_address: Option<String>,
38    /// City.
39    pub city: Option<String>,
40    /// State.
41    pub state: Option<String>,
42    /// ZIP code.
43    pub zip_code: Option<String>,
44    /// PO Box.
45    pub po_box: Option<String>,
46    /// Operator class (for amateur).
47    pub operator_class: Option<char>,
48    /// Previous call sign.
49    pub previous_call_sign: Option<String>,
50}
51
52impl License {
53    /// Get the formatted name (entity name or "First Last").
54    pub fn display_name(&self) -> String {
55        if let (Some(first), Some(last)) = (&self.first_name, &self.last_name) {
56            if let Some(mi) = &self.middle_initial {
57                format!("{} {} {}", first, mi, last)
58            } else {
59                format!("{} {}", first, last)
60            }
61        } else {
62            self.licensee_name.clone()
63        }
64    }
65
66    /// Get the status description.
67    pub fn status_description(&self) -> &'static str {
68        match self.status {
69            'A' => "Active",
70            'C' => "Cancelled",
71            'E' => "Expired",
72            'L' => "Pending Legal Status",
73            'P' => "Parent Station Cancelled",
74            'T' => "Terminated",
75            'X' => "Term Pending",
76            _ => "Unknown",
77        }
78    }
79
80    /// Check if the license is active.
81    pub fn is_active(&self) -> bool {
82        self.status == 'A'
83    }
84
85    /// Get the operator class description (amateur only).
86    pub fn operator_class_description(&self) -> Option<&'static str> {
87        self.operator_class.map(|c| match c {
88            'T' => "Technician",
89            'G' => "General",
90            'A' => "Advanced",
91            'E' => "Amateur Extra",
92            'N' => "Novice",
93            'P' => "Technician Plus",
94            _ => "Unknown",
95        })
96    }
97
98    /// Get a field value by name for dynamic output.
99    pub fn get_field(&self, name: &str) -> Option<String> {
100        match name.to_lowercase().as_str() {
101            "call_sign" | "callsign" | "call" => Some(self.call_sign.clone()),
102            "name" | "licensee" | "entity_name" => Some(self.display_name()),
103            "first_name" | "first" => self.first_name.clone(),
104            "last_name" | "last" => self.last_name.clone(),
105            "middle_initial" | "mi" => self.middle_initial.clone(),
106            "status" | "license_status" => Some(self.status.to_string()),
107            "status_desc" | "status_description" => Some(self.status_description().to_string()),
108            "service" | "radio_service" => Some(self.radio_service.clone()),
109            "class" | "operator_class" => self.operator_class.map(|c| c.to_string()),
110            "class_desc" | "class_description" => {
111                self.operator_class_description().map(|s| s.to_string())
112            }
113            "city" => self.city.clone(),
114            "state" => self.state.clone(),
115            "zip" | "zip_code" => self.zip_code.clone(),
116            "location" => {
117                let city = self.city.as_deref().unwrap_or("");
118                let state = self.state.as_deref().unwrap_or("");
119                if city.is_empty() && state.is_empty() {
120                    None
121                } else {
122                    Some(format!("{}, {}", city, state))
123                }
124            }
125            "address" | "street_address" => self.street_address.clone(),
126            "po_box" => self.po_box.clone(),
127            "frn" => self.frn.clone(),
128            "grant_date" | "granted" => self.grant_date.map(|d| d.to_string()),
129            "expired_date" | "expires" | "expiration" => self.expired_date.map(|d| d.to_string()),
130            "cancellation_date" | "cancelled" => self.cancellation_date.map(|d| d.to_string()),
131            "previous_call_sign" | "previous_call" => self.previous_call_sign.clone(),
132            "usi" | "unique_system_identifier" => Some(self.unique_system_identifier.to_string()),
133            _ => None,
134        }
135    }
136
137    /// Get list of available field names.
138    pub fn field_names() -> &'static [&'static str] {
139        &[
140            "call_sign",
141            "name",
142            "first_name",
143            "last_name",
144            "status",
145            "status_desc",
146            "service",
147            "class",
148            "class_desc",
149            "city",
150            "state",
151            "zip",
152            "location",
153            "address",
154            "po_box",
155            "frn",
156            "grant_date",
157            "expired_date",
158            "cancellation_date",
159            "previous_call_sign",
160            "usi",
161        ]
162    }
163}
164
165/// Amateur operator information.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Operator {
168    /// Unique system identifier.
169    pub unique_system_identifier: i64,
170    /// Call sign.
171    pub call_sign: String,
172    /// Operator class.
173    pub operator_class: char,
174    /// Group code.
175    pub group_code: Option<char>,
176    /// Region code.
177    pub region_code: Option<i32>,
178    /// Vanity call indicator.
179    pub vanity_call: bool,
180    /// Previous operator class.
181    pub previous_operator_class: Option<char>,
182}
183
184/// Database statistics.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct LicenseStats {
187    /// Total number of licenses.
188    pub total_licenses: u64,
189    /// Number of active licenses.
190    pub active_licenses: u64,
191    /// Number of expired licenses.
192    pub expired_licenses: u64,
193    /// Number of cancelled licenses.
194    pub cancelled_licenses: u64,
195    /// Breakdown by radio service.
196    pub by_service: Vec<(String, u64)>,
197    /// Breakdown by operator class (amateur only).
198    pub by_operator_class: Vec<(String, u64)>,
199    /// Database last updated.
200    pub last_updated: Option<String>,
201    /// Database schema version.
202    pub schema_version: i32,
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_license_display_name() {
211        let mut license = License {
212            unique_system_identifier: 1,
213            call_sign: "W1AW".to_string(),
214            licensee_name: "ARRL".to_string(),
215            first_name: Some("Test".to_string()),
216            middle_initial: Some("X".to_string()),
217            last_name: Some("User".to_string()),
218            status: 'A',
219            radio_service: "HA".to_string(),
220            grant_date: None,
221            expired_date: None,
222            cancellation_date: None,
223            frn: None,
224            street_address: None,
225            city: None,
226            state: None,
227            zip_code: None,
228            po_box: None,
229            operator_class: Some('E'),
230            previous_call_sign: None,
231        };
232
233        assert_eq!(license.display_name(), "Test X User");
234
235        license.first_name = None;
236        assert_eq!(license.display_name(), "ARRL");
237    }
238
239    #[test]
240    fn test_operator_class_description() {
241        let license = License {
242            unique_system_identifier: 1,
243            call_sign: "W1AW".to_string(),
244            licensee_name: "Test".to_string(),
245            first_name: None,
246            middle_initial: None,
247            last_name: None,
248            status: 'A',
249            radio_service: "HA".to_string(),
250            grant_date: None,
251            expired_date: None,
252            cancellation_date: None,
253            frn: None,
254            street_address: None,
255            city: None,
256            state: None,
257            zip_code: None,
258            po_box: None,
259            operator_class: Some('E'),
260            previous_call_sign: None,
261        };
262
263        assert_eq!(license.operator_class_description(), Some("Amateur Extra"));
264        assert!(license.is_active());
265    }
266
267    #[test]
268    fn test_status_description_all_variants() {
269        let mut license = License {
270            unique_system_identifier: 1,
271            call_sign: "W1AW".to_string(),
272            licensee_name: "Test".to_string(),
273            first_name: None,
274            middle_initial: None,
275            last_name: None,
276            status: 'A',
277            radio_service: "HA".to_string(),
278            grant_date: None,
279            expired_date: None,
280            cancellation_date: None,
281            frn: None,
282            street_address: None,
283            city: None,
284            state: None,
285            zip_code: None,
286            po_box: None,
287            operator_class: None,
288            previous_call_sign: None,
289        };
290
291        assert_eq!(license.status_description(), "Active");
292
293        license.status = 'C';
294        assert_eq!(license.status_description(), "Cancelled");
295
296        license.status = 'E';
297        assert_eq!(license.status_description(), "Expired");
298
299        license.status = 'L';
300        assert_eq!(license.status_description(), "Pending Legal Status");
301
302        license.status = 'P';
303        assert_eq!(license.status_description(), "Parent Station Cancelled");
304
305        license.status = 'T';
306        assert_eq!(license.status_description(), "Terminated");
307
308        license.status = 'X';
309        assert_eq!(license.status_description(), "Term Pending");
310
311        license.status = 'Z';
312        assert_eq!(license.status_description(), "Unknown");
313    }
314
315    #[test]
316    fn test_operator_class_all_variants() {
317        let mut license = License {
318            unique_system_identifier: 1,
319            call_sign: "W1AW".to_string(),
320            licensee_name: "Test".to_string(),
321            first_name: None,
322            middle_initial: None,
323            last_name: None,
324            status: 'A',
325            radio_service: "HA".to_string(),
326            grant_date: None,
327            expired_date: None,
328            cancellation_date: None,
329            frn: None,
330            street_address: None,
331            city: None,
332            state: None,
333            zip_code: None,
334            po_box: None,
335            operator_class: Some('T'),
336            previous_call_sign: None,
337        };
338
339        assert_eq!(license.operator_class_description(), Some("Technician"));
340
341        license.operator_class = Some('G');
342        assert_eq!(license.operator_class_description(), Some("General"));
343
344        license.operator_class = Some('A');
345        assert_eq!(license.operator_class_description(), Some("Advanced"));
346
347        license.operator_class = Some('N');
348        assert_eq!(license.operator_class_description(), Some("Novice"));
349
350        license.operator_class = Some('P');
351        assert_eq!(
352            license.operator_class_description(),
353            Some("Technician Plus")
354        );
355
356        license.operator_class = Some('Z');
357        assert_eq!(license.operator_class_description(), Some("Unknown"));
358
359        license.operator_class = None;
360        assert_eq!(license.operator_class_description(), None);
361    }
362
363    #[test]
364    fn test_display_name_without_middle_initial() {
365        let license = License {
366            unique_system_identifier: 1,
367            call_sign: "W1AW".to_string(),
368            licensee_name: "ARRL".to_string(),
369            first_name: Some("John".to_string()),
370            middle_initial: None,
371            last_name: Some("Doe".to_string()),
372            status: 'A',
373            radio_service: "HA".to_string(),
374            grant_date: None,
375            expired_date: None,
376            cancellation_date: None,
377            frn: None,
378            street_address: None,
379            city: None,
380            state: None,
381            zip_code: None,
382            po_box: None,
383            operator_class: None,
384            previous_call_sign: None,
385        };
386
387        assert_eq!(license.display_name(), "John Doe");
388    }
389
390    #[test]
391    fn test_get_field_basic() {
392        let license = License {
393            unique_system_identifier: 12345,
394            call_sign: "W1AW".to_string(),
395            licensee_name: "ARRL".to_string(),
396            first_name: Some("John".to_string()),
397            middle_initial: Some("Q".to_string()),
398            last_name: Some("Public".to_string()),
399            status: 'A',
400            radio_service: "HA".to_string(),
401            grant_date: Some(NaiveDate::from_ymd_opt(2020, 1, 15).unwrap()),
402            expired_date: Some(NaiveDate::from_ymd_opt(2030, 1, 15).unwrap()),
403            cancellation_date: None,
404            frn: Some("0012345678".to_string()),
405            street_address: Some("123 Main St".to_string()),
406            city: Some("Newington".to_string()),
407            state: Some("CT".to_string()),
408            zip_code: Some("06111".to_string()),
409            po_box: None,
410            operator_class: Some('E'),
411            previous_call_sign: Some("N1XYZ".to_string()),
412        };
413
414        // Test all field name variants
415        assert_eq!(license.get_field("call_sign"), Some("W1AW".to_string()));
416        assert_eq!(license.get_field("callsign"), Some("W1AW".to_string()));
417        assert_eq!(license.get_field("call"), Some("W1AW".to_string()));
418
419        assert_eq!(license.get_field("name"), Some("John Q Public".to_string()));
420        assert_eq!(
421            license.get_field("licensee"),
422            Some("John Q Public".to_string())
423        );
424
425        assert_eq!(license.get_field("first_name"), Some("John".to_string()));
426        assert_eq!(license.get_field("first"), Some("John".to_string()));
427
428        assert_eq!(license.get_field("last_name"), Some("Public".to_string()));
429        assert_eq!(license.get_field("last"), Some("Public".to_string()));
430
431        assert_eq!(license.get_field("middle_initial"), Some("Q".to_string()));
432        assert_eq!(license.get_field("mi"), Some("Q".to_string()));
433
434        assert_eq!(license.get_field("status"), Some("A".to_string()));
435        assert_eq!(license.get_field("status_desc"), Some("Active".to_string()));
436
437        assert_eq!(license.get_field("service"), Some("HA".to_string()));
438        assert_eq!(license.get_field("radio_service"), Some("HA".to_string()));
439
440        assert_eq!(license.get_field("class"), Some("E".to_string()));
441        assert_eq!(
442            license.get_field("class_desc"),
443            Some("Amateur Extra".to_string())
444        );
445
446        assert_eq!(license.get_field("city"), Some("Newington".to_string()));
447        assert_eq!(license.get_field("state"), Some("CT".to_string()));
448        assert_eq!(license.get_field("zip"), Some("06111".to_string()));
449        assert_eq!(license.get_field("zip_code"), Some("06111".to_string()));
450
451        assert_eq!(
452            license.get_field("location"),
453            Some("Newington, CT".to_string())
454        );
455
456        assert_eq!(
457            license.get_field("address"),
458            Some("123 Main St".to_string())
459        );
460        assert_eq!(
461            license.get_field("street_address"),
462            Some("123 Main St".to_string())
463        );
464
465        assert_eq!(license.get_field("frn"), Some("0012345678".to_string()));
466
467        assert_eq!(
468            license.get_field("grant_date"),
469            Some("2020-01-15".to_string())
470        );
471        assert_eq!(license.get_field("granted"), Some("2020-01-15".to_string()));
472
473        assert_eq!(
474            license.get_field("expired_date"),
475            Some("2030-01-15".to_string())
476        );
477        assert_eq!(license.get_field("expires"), Some("2030-01-15".to_string()));
478
479        assert_eq!(license.get_field("cancellation_date"), None);
480        assert_eq!(license.get_field("cancelled"), None);
481
482        assert_eq!(
483            license.get_field("previous_call_sign"),
484            Some("N1XYZ".to_string())
485        );
486        assert_eq!(
487            license.get_field("previous_call"),
488            Some("N1XYZ".to_string())
489        );
490
491        assert_eq!(license.get_field("usi"), Some("12345".to_string()));
492        assert_eq!(
493            license.get_field("unique_system_identifier"),
494            Some("12345".to_string())
495        );
496
497        // Unknown field
498        assert_eq!(license.get_field("unknown_field"), None);
499    }
500
501    #[test]
502    fn test_get_field_location_empty() {
503        let license = License {
504            unique_system_identifier: 1,
505            call_sign: "W1AW".to_string(),
506            licensee_name: "Test".to_string(),
507            first_name: None,
508            middle_initial: None,
509            last_name: None,
510            status: 'A',
511            radio_service: "HA".to_string(),
512            grant_date: None,
513            expired_date: None,
514            cancellation_date: None,
515            frn: None,
516            street_address: None,
517            city: None,
518            state: None,
519            zip_code: None,
520            po_box: None,
521            operator_class: None,
522            previous_call_sign: None,
523        };
524
525        // Empty city and state should return None for location
526        assert_eq!(license.get_field("location"), None);
527    }
528
529    #[test]
530    fn test_get_field_no_operator_class() {
531        let license = License {
532            unique_system_identifier: 1,
533            call_sign: "W1AW".to_string(),
534            licensee_name: "Test".to_string(),
535            first_name: None,
536            middle_initial: None,
537            last_name: None,
538            status: 'A',
539            radio_service: "ZA".to_string(), // GMRS has no class
540            grant_date: None,
541            expired_date: None,
542            cancellation_date: None,
543            frn: None,
544            street_address: None,
545            city: None,
546            state: None,
547            zip_code: None,
548            po_box: None,
549            operator_class: None,
550            previous_call_sign: None,
551        };
552
553        assert_eq!(license.get_field("class"), None);
554        assert_eq!(license.get_field("class_desc"), None);
555    }
556
557    #[test]
558    fn test_field_names() {
559        let names = License::field_names();
560        assert!(names.contains(&"call_sign"));
561        assert!(names.contains(&"name"));
562        assert!(names.contains(&"status"));
563        assert!(names.contains(&"city"));
564        assert!(names.contains(&"state"));
565        assert!(names.contains(&"frn"));
566        assert!(names.contains(&"grant_date"));
567        assert!(names.len() >= 15);
568    }
569
570    #[test]
571    fn test_is_active() {
572        let mut license = License {
573            unique_system_identifier: 1,
574            call_sign: "W1AW".to_string(),
575            licensee_name: "Test".to_string(),
576            first_name: None,
577            middle_initial: None,
578            last_name: None,
579            status: 'A',
580            radio_service: "HA".to_string(),
581            grant_date: None,
582            expired_date: None,
583            cancellation_date: None,
584            frn: None,
585            street_address: None,
586            city: None,
587            state: None,
588            zip_code: None,
589            po_box: None,
590            operator_class: None,
591            previous_call_sign: None,
592        };
593
594        assert!(license.is_active());
595
596        license.status = 'E';
597        assert!(!license.is_active());
598
599        license.status = 'C';
600        assert!(!license.is_active());
601    }
602
603    #[test]
604    fn test_get_field_cancellation_date_with_value() {
605        // Tests the cancellation_date path when a date IS present
606        // (the lambda inside .map() was never exercised before)
607        let license = License {
608            unique_system_identifier: 1,
609            call_sign: "W1AW".to_string(),
610            licensee_name: "Test".to_string(),
611            first_name: None,
612            middle_initial: None,
613            last_name: None,
614            status: 'C',
615            radio_service: "HA".to_string(),
616            grant_date: None,
617            expired_date: None,
618            cancellation_date: Some(NaiveDate::from_ymd_opt(2023, 6, 15).unwrap()),
619            frn: None,
620            street_address: None,
621            city: None,
622            state: None,
623            zip_code: None,
624            po_box: None,
625            operator_class: None,
626            previous_call_sign: None,
627        };
628
629        assert_eq!(
630            license.get_field("cancellation_date"),
631            Some("2023-06-15".to_string())
632        );
633        assert_eq!(
634            license.get_field("cancelled"),
635            Some("2023-06-15".to_string())
636        );
637    }
638
639    #[test]
640    fn test_get_field_location_partial() {
641        // Tests location field with only city or only state present
642        let mut license = License {
643            unique_system_identifier: 1,
644            call_sign: "W1AW".to_string(),
645            licensee_name: "Test".to_string(),
646            first_name: None,
647            middle_initial: None,
648            last_name: None,
649            status: 'A',
650            radio_service: "HA".to_string(),
651            grant_date: None,
652            expired_date: None,
653            cancellation_date: None,
654            frn: None,
655            street_address: None,
656            city: Some("Boston".to_string()),
657            state: None,
658            zip_code: None,
659            po_box: None,
660            operator_class: None,
661            previous_call_sign: None,
662        };
663
664        // Only city, no state - should still format
665        assert_eq!(license.get_field("location"), Some("Boston, ".to_string()));
666
667        // Only state, no city
668        license.city = None;
669        license.state = Some("MA".to_string());
670        assert_eq!(license.get_field("location"), Some(", MA".to_string()));
671    }
672
673    #[test]
674    fn test_get_field_expiration_alias() {
675        // Ensure the "expiration" alias works for expired_date
676        let license = License {
677            unique_system_identifier: 1,
678            call_sign: "W1AW".to_string(),
679            licensee_name: "Test".to_string(),
680            first_name: None,
681            middle_initial: None,
682            last_name: None,
683            status: 'A',
684            radio_service: "HA".to_string(),
685            grant_date: None,
686            expired_date: Some(NaiveDate::from_ymd_opt(2030, 12, 31).unwrap()),
687            cancellation_date: None,
688            frn: None,
689            street_address: None,
690            city: None,
691            state: None,
692            zip_code: None,
693            po_box: None,
694            operator_class: None,
695            previous_call_sign: None,
696        };
697
698        assert_eq!(
699            license.get_field("expiration"),
700            Some("2030-12-31".to_string())
701        );
702    }
703
704    #[test]
705    fn test_get_field_entity_name_alias() {
706        // Ensure the "entity_name" alias works for display_name
707        let license = License {
708            unique_system_identifier: 1,
709            call_sign: "W1AW".to_string(),
710            licensee_name: "ARRL HQ Station".to_string(),
711            first_name: None,
712            middle_initial: None,
713            last_name: None,
714            status: 'A',
715            radio_service: "HA".to_string(),
716            grant_date: None,
717            expired_date: None,
718            cancellation_date: None,
719            frn: None,
720            street_address: None,
721            city: None,
722            state: None,
723            zip_code: None,
724            po_box: None,
725            operator_class: None,
726            previous_call_sign: None,
727        };
728
729        // When no first/last name, should return licensee_name
730        assert_eq!(
731            license.get_field("entity_name"),
732            Some("ARRL HQ Station".to_string())
733        );
734    }
735
736    #[test]
737    fn test_get_field_license_status_alias() {
738        // Ensure the "license_status" alias works for status
739        let license = License {
740            unique_system_identifier: 1,
741            call_sign: "W1AW".to_string(),
742            licensee_name: "Test".to_string(),
743            first_name: None,
744            middle_initial: None,
745            last_name: None,
746            status: 'E',
747            radio_service: "HA".to_string(),
748            grant_date: None,
749            expired_date: None,
750            cancellation_date: None,
751            frn: None,
752            street_address: None,
753            city: None,
754            state: None,
755            zip_code: None,
756            po_box: None,
757            operator_class: None,
758            previous_call_sign: None,
759        };
760
761        assert_eq!(license.get_field("license_status"), Some("E".to_string()));
762    }
763}