Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
704 changes: 704 additions & 0 deletions migrations/20260829000000_add_atc_positions.sql

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use utoipa_scalar::{Scalar, Servable};
use crate::error::ApiError;
use crate::modules::atc_application::routes::build_atc_application_routes;
use crate::modules::atc_booking::routes::build_atc_booking_routes;
use crate::modules::atc_position::routes::build_atc_position_routes;
use crate::modules::audit_log::routes::build_audit_log_routes;
use crate::modules::controller::routes::controllers::build_atc_routes;
use crate::modules::controller::routes::sectors::build_sector_routes;
Expand Down Expand Up @@ -58,6 +59,7 @@ pub fn router(services: Services) -> Router {
.nest("/api/atc/controllers", build_atc_routes())
.nest("/api/atc/applications", build_atc_application_routes())
.nest("/api/atc/bookings", build_atc_booking_routes())
.nest("/api/atc/positions", build_atc_position_routes())
.nest("/api/compat", build_compat_routes())
.nest(
"/api/navdata/preferred-routes",
Expand Down
13 changes: 13 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use crate::adapter::smms::SmmsError;
use crate::modules::atc_application::models::InvalidAtcApplicationStatus;
use crate::modules::atc_application::service::AtcApplicationServiceError;
use crate::modules::atc_booking::service::AtcBookingServiceError;
use crate::modules::atc_position::service::AtcPositionServiceError;
use crate::modules::audit_log::service::AuditLogServiceError;
use crate::modules::controller::service::ControllerServiceError;
use crate::modules::event::service::EventServiceError;
Expand Down Expand Up @@ -239,6 +240,18 @@ impl From<AtcBookingServiceError> for ApiError {
}
}

impl From<AtcPositionServiceError> for ApiError {
fn from(error: AtcPositionServiceError) -> Self {
match error {
AtcPositionServiceError::NotFound(callsign) => {
ApiError::not_found("ATC position", callsign)
}
AtcPositionServiceError::Database(source) => ApiError::Database { source },
AtcPositionServiceError::AuditLog(source) => ApiError::AuditLog { source },
}
}
}

impl From<TrainingServiceError> for ApiError {
fn from(error: TrainingServiceError) -> Self {
match error {
Expand Down
163 changes: 163 additions & 0 deletions src/modules/atc_position/dto.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::error::ApiError;

use super::models::{AtcPosition, AtcPositionCategory, AtcPositionSave};

#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct AtcPositionSaveRequest {
pub category: AtcPositionCategory,
pub callsign: String,
pub is_tier_2: bool,
pub callsign_zh: Option<String>,
pub callsign_en: Option<String>,
/// Radio frequency in MHz, for example `118.500`.
pub frequency: f64,
pub cpdlc_code: Option<String>,
pub remarks: Option<String>,
}

#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct AtcPositionDto {
pub category: AtcPositionCategory,
pub callsign: String,
pub is_tier_2: bool,
pub callsign_zh: Option<String>,
pub callsign_en: Option<String>,
/// Radio frequency in MHz.
pub frequency: f64,
/// Exact radio frequency in kHz for machine consumers.
pub frequency_khz: i32,
pub cpdlc_code: Option<String>,
pub remarks: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

impl TryFrom<AtcPositionSaveRequest> for AtcPositionSave {
type Error = ApiError;

fn try_from(request: AtcPositionSaveRequest) -> Result<Self, Self::Error> {
let callsign = normalize_callsign(request.callsign)?;
let frequency_khz = frequency_to_khz(request.frequency)?;

Ok(Self {
category: request.category,
callsign,
is_tier_2: request.is_tier_2,
callsign_zh: optional_trimmed(request.callsign_zh),
callsign_en: optional_trimmed(request.callsign_en),
frequency_khz,
cpdlc_code: optional_trimmed(request.cpdlc_code),
remarks: optional_trimmed(request.remarks),
})
}
}

impl TryFrom<AtcPosition> for AtcPositionDto {
type Error = ApiError;

fn try_from(position: AtcPosition) -> Result<Self, Self::Error> {
let category = position.category.parse().map_err(|_| {
ApiError::invalid_database_value("atc_position.category", &position.category)
})?;

Ok(Self {
category,
callsign: position.callsign,
is_tier_2: position.is_tier_2,
callsign_zh: position.callsign_zh,
callsign_en: position.callsign_en,
frequency: f64::from(position.frequency_khz) / 1000.0,
frequency_khz: position.frequency_khz,
cpdlc_code: position.cpdlc_code,
remarks: position.remarks,
created_at: position.created_at,
updated_at: position.updated_at,
})
}
}

fn frequency_to_khz(frequency: f64) -> Result<i32, ApiError> {
if !frequency.is_finite() {
return Err(ApiError::bad_request(
"frequency",
"must be a finite number",
));
}

let frequency_khz = (frequency * 1000.0).round();
if !(118_000.0..=136_975.0).contains(&frequency_khz)
|| (frequency * 1000.0 - frequency_khz).abs() > 0.000_1
|| frequency_khz as i32 % 5 != 0
{
return Err(ApiError::bad_request(
"frequency",
"must be a valid 5 kHz VHF channel between 118.000 and 136.975 MHz",
));
}

Ok(frequency_khz as i32)
}

pub(crate) fn normalize_callsign(callsign: String) -> Result<String, ApiError> {
let callsign = required_trimmed("callsign", callsign)?.to_ascii_uppercase();
if callsign.len() > 32
|| !callsign
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '*'))
{
return Err(ApiError::bad_request(
"callsign",
"must contain only ASCII letters, digits, underscores, or wildcards and be at most 32 characters",
));
}
Ok(callsign)
}

fn required_trimmed(field: &str, value: String) -> Result<String, ApiError> {
let value = value.trim();
if value.is_empty() {
return Err(ApiError::bad_request(field, "must not be empty"));
}
Ok(value.to_owned())
}

fn optional_trimmed(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}

#[cfg(test)]
mod tests {
use super::{frequency_to_khz, normalize_callsign};

#[test]
fn converts_valid_frequency_to_exact_khz() {
assert_eq!(frequency_to_khz(121.825).unwrap(), 121_825);
assert_eq!(frequency_to_khz(131.010).unwrap(), 131_010);
}

#[test]
fn rejects_invalid_frequency() {
assert!(frequency_to_khz(117.995).is_err());
assert!(frequency_to_khz(121.823).is_err());
assert!(frequency_to_khz(f64::NAN).is_err());
}

#[test]
fn normalizes_and_validates_callsign() {
assert_eq!(
normalize_callsign(" zbaa_twr ".to_owned()).unwrap(),
"ZBAA_TWR"
);
assert_eq!(
normalize_callsign("*_mil_twr".to_owned()).unwrap(),
"*_MIL_TWR"
);
assert!(normalize_callsign("ZBAA TWR".to_owned()).is_err());
assert!(normalize_callsign("ZBAA_TWR(停用)".to_owned()).is_err());
}
}
5 changes: 5 additions & 0 deletions src/modules/atc_position/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pub mod dto;
pub mod models;
pub mod repository;
pub mod routes;
pub mod service;
52 changes: 52 additions & 0 deletions src/modules/atc_position/models.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize, de::IntoDeserializer};
use sqlx::FromRow;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum AtcPositionCategory {
Standard,
ChengduLowArea,
Military,
Atis,
}

impl std::fmt::Display for AtcPositionCategory {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.serialize(formatter)
}
}

impl std::str::FromStr for AtcPositionCategory {
type Err = serde::de::value::Error;

fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::deserialize(value.into_deserializer())
}
}

#[derive(Debug, Clone, FromRow, Serialize)]
pub struct AtcPosition {
pub category: String,
pub callsign: String,
pub is_tier_2: bool,
pub callsign_zh: Option<String>,
pub callsign_en: Option<String>,
pub frequency_khz: i32,
pub cpdlc_code: Option<String>,
pub remarks: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone)]
pub struct AtcPositionSave {
pub category: AtcPositionCategory,
pub callsign: String,
pub is_tier_2: bool,
pub callsign_zh: Option<String>,
pub callsign_en: Option<String>,
pub frequency_khz: i32,
pub cpdlc_code: Option<String>,
pub remarks: Option<String>,
}
Loading