diff --git a/app/api/dependencies/auth.py b/app/api/dependencies/auth.py index 34f3316..e0f8a47 100644 --- a/app/api/dependencies/auth.py +++ b/app/api/dependencies/auth.py @@ -1,4 +1,5 @@ import jwt +from typing import Dict, Union from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer @@ -11,7 +12,7 @@ -def authenticate_user(username: str, password: str) -> dict[str, str | bool]: +def authenticate_user(username: str, password: str) -> Dict[str, Union[str, bool]]: if settings.ENV == "dev": return {"status": "success", "message": "ok", "result": True} else: @@ -47,8 +48,8 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: # Function to decode a JWT token using the specified secret and algorithm -def unhash(token: str) -> dict[str, str]: +def unhash(token: str) -> Dict[str, str]: return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.ALG]) # type: ignore[no-any-return] -def hash(payload: dict[str, str]) -> str: +def hash(payload: Dict[str, str]) -> str: return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.ALG) diff --git a/app/api/dependencies/pytas.py b/app/api/dependencies/pytas.py index 60f3177..6a60229 100644 --- a/app/api/dependencies/pytas.py +++ b/app/api/dependencies/pytas.py @@ -6,13 +6,14 @@ from app.pytas.http import TASClient # type: ignore[attr-defined] from app.core.config import get_settings +from typing import List settings = get_settings() ENVIRONMENT = settings.ENV dev_allocations = ["WEATHER-456", "WEATHER-457", "WEATHER-458", "TEST-123", "string"] -def get_allocations(username: str) -> list[str]: +def get_allocations(username: str) -> List[str]: if ENVIRONMENT == "dev": return dev_allocations else: diff --git a/app/api/v1/routes/campaigns/campaign_station_sensor_measurements.py b/app/api/v1/routes/campaigns/campaign_station_sensor_measurements.py index 9a77dfe..3ea2a4f 100644 --- a/app/api/v1/routes/campaigns/campaign_station_sensor_measurements.py +++ b/app/api/v1/routes/campaigns/campaign_station_sensor_measurements.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional, Union from sqlalchemy.orm import Session from fastapi import APIRouter, Depends, HTTPException, Query, Response @@ -38,14 +38,14 @@ async def get_sensor_measurements( campaign_id: int, station_id: int, sensor_id: int, - start_date: datetime | None = None, - end_date: datetime | None = None, - min_measurement_value: float | None = None, - max_measurement_value: float | None = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + min_measurement_value: Optional[float] = None, + max_measurement_value: Optional[float] = None, current_user: User = Depends(get_current_user), limit: int = 1000, page: int = 1, - downsample_threshold: int | None = None, + downsample_threshold: Optional[int] = None, db: Session = Depends(get_db), ) -> ListMeasurementsResponsePagination: if not check_allocation_permission(current_user, campaign_id): @@ -54,19 +54,19 @@ async def get_sensor_measurements( measurement_service = MeasurementService(measurement_repository) return measurement_service.list_measurements(sensor_id=sensor_id, start_date=start_date, end_date=end_date, min_value=min_measurement_value, max_value=max_measurement_value, page=page, limit=limit, downsample_threshold=downsample_threshold) -@router.get("/measurements/confidence-intervals", response_model=list[AggregatedMeasurement]) +@router.get("/measurements/confidence-intervals", response_model=List[AggregatedMeasurement]) async def get_measurements_with_confidence_intervals( campaign_id: int, station_id: int, sensor_id: int, interval: str = Query("hour", description="Time interval for aggregation (minute, hour, day)"), interval_value: int = Query(1, description="Multiple of interval (e.g., 15 for 15-minute intervals)"), - start_date: datetime | None = Query(None, description="Start date for filtering measurements"), - end_date: datetime | None = Query(None, description="End date for filtering measurements"), - min_value: float | None = Query(None, description="Minimum measurement value to include"), - max_value: float | None = Query(None, description="Maximum measurement value to include"), + start_date: Optional[datetime] = Query(None, description="Start date for filtering measurements"), + end_date: Optional[datetime] = Query(None, description="End date for filtering measurements"), + min_value: Optional[float] = Query(None, description="Minimum measurement value to include"), + max_value: Optional[float] = Query(None, description="Maximum measurement value to include"), db: Session = Depends(get_db) -) -> list[AggregatedMeasurement]: +) -> List[AggregatedMeasurement]: """Get sensor measurements with confidence intervals for visualization.""" measurement_repository = MeasurementRepository(db) measurement_service = MeasurementService(measurement_repository) diff --git a/app/api/v1/routes/campaigns/campaign_station_sensors.py b/app/api/v1/routes/campaigns/campaign_station_sensors.py index cf74d0f..2f0d629 100644 --- a/app/api/v1/routes/campaigns/campaign_station_sensors.py +++ b/app/api/v1/routes/campaigns/campaign_station_sensors.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Union from sqlalchemy.orm import Session from fastapi import APIRouter, Depends, HTTPException, Query, Response @@ -25,10 +25,10 @@ async def list_sensors( station_id: int, page: int = 1, limit: int = 20, - variable_name: str | None = Query(None, description="Filter sensors by variable name (partial match)"), - units: str | None = Query(None, description="Filter sensors by units (exact match)"), - alias: str | None = Query(None, description="Filter sensors by alias (partial match)"), - description_contains: str | None = Query(None, description="Filter sensors by text in description (partial match)"), + variable_name: Optional[str] = Query(None, description="Filter sensors by variable name (partial match)"), + units: Optional[str] = Query(None, description="Filter sensors by units (exact match)"), + alias: Optional[str] = Query(None, description="Filter sensors by alias (partial match)"), + description_contains: Optional[str] = Query(None, description="Filter sensors by text in description (partial match)"), postprocess: Optional[bool] = Query(None, description="Filter sensors by postprocess flag"), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), @@ -87,7 +87,7 @@ async def get_sensor( @router.delete("/sensors", status_code=204) -def delete_sensor( +def delete_sensors( campaign_id: int, station_id: int, db: Session = Depends(get_db), @@ -101,6 +101,29 @@ def delete_sensor( return Response(status_code=204) +@router.delete("/sensors/{sensor_id}", status_code=204) +def delete_sensor( + campaign_id: int, + station_id: int, + sensor_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> Response: + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + + sensor_service = SensorService( + sensor_repository=SensorRepository(db), + measurement_repository=MeasurementRepository(db) + ) + + success = sensor_service.delete_sensor(sensor_id) + if not success: + raise HTTPException(status_code=404, detail="Sensor not found") + + return Response(status_code=204) + + @router.put("/sensors/{sensor_id}", response_model=SensorCreateResponse) def update_sensor( diff --git a/app/api/v1/routes/campaigns/campaign_stations.py b/app/api/v1/routes/campaigns/campaign_stations.py index 14ce833..9fc9dd5 100644 --- a/app/api/v1/routes/campaigns/campaign_stations.py +++ b/app/api/v1/routes/campaigns/campaign_stations.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Annotated +from typing import Annotated, Optional from app.services.campaign_service import CampaignService from fastapi import APIRouter, Depends, HTTPException, Query, Response @@ -162,9 +162,9 @@ async def export_measurements_csv( campaign_id: int, station_id: int, start_date: Annotated[ - datetime | None, Query(description="Start date filter") + Optional[datetime], Query(description="Start date filter") ] = None, - end_date: Annotated[datetime | None, Query(description="End date filter")] = None, + end_date: Annotated[Optional[datetime], Query(description="End date filter")] = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> StreamingResponse: diff --git a/app/api/v1/routes/campaigns/root.py b/app/api/v1/routes/campaigns/root.py index 41caaac..960ba78 100644 --- a/app/api/v1/routes/campaigns/root.py +++ b/app/api/v1/routes/campaigns/root.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Annotated +from typing import Annotated, Optional, List from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy.orm import Session @@ -38,19 +38,19 @@ async def list_campaigns( page: int = 1, limit: int = 20, bbox: Annotated[ - str | None, + Optional[str], Query(description="Bounding box of the campaign west,south,east,north"), ] = None, start_date: Annotated[ - datetime | None, - Query(description="Start date of the campaign", example="2024-01-01"), + Optional[datetime], + Query(description="Start date of the campaign", examples=["2024-01-01"]), ] = None, end_date: Annotated[ - datetime | None, - Query(description="End date of the campaign", example="2025-01-01"), + Optional[datetime], + Query(description="End date of the campaign", examples=["2025-01-01"]), ] = None, sensor_variables: Annotated[ - list[str] | None, Query(description="List of sensor variables to filter by") + Optional[List[str]], Query(description="List of sensor variables to filter by") ] = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), @@ -84,7 +84,7 @@ async def get_campaign( @router.delete("/{campaign_id}", status_code=204) -def delete_sensor( +def delete_campaign( campaign_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), diff --git a/app/api/v1/routes/projects/projects.py b/app/api/v1/routes/projects/projects.py index 447fcec..55c6487 100644 --- a/app/api/v1/routes/projects/projects.py +++ b/app/api/v1/routes/projects/projects.py @@ -4,13 +4,14 @@ from app.api.v1.schemas.user import User from app.services.project_service import ProjectService from app.pytas.models.schemas import PyTASProject, PyTASUser +from typing import List router = APIRouter(prefix="", tags=["projects"]) @router.get("/projects") -async def get_projects(current_user: User = Depends(get_current_user)) -> list[PyTASProject]: +async def get_projects(current_user: User = Depends(get_current_user)) -> List[PyTASProject]: return ProjectService().get_projects_for_user(current_user.username) @router.get("/projects/{project_id}/members") -async def get_project_members_for_user(project_id: str) -> list[PyTASUser]: +async def get_project_members_for_user(project_id: str) -> List[PyTASUser]: return ProjectService().get_project_members(project_id) \ No newline at end of file diff --git a/app/api/v1/routes/root.py b/app/api/v1/routes/root.py index 55ddc1e..c809064 100644 --- a/app/api/v1/routes/root.py +++ b/app/api/v1/routes/root.py @@ -1,3 +1,4 @@ +from typing import Dict # mypy: allow-untyped-calls import jwt diff --git a/app/api/v1/routes/sensor_variables/sensor_variables.py b/app/api/v1/routes/sensor_variables/sensor_variables.py index f0ddc80..d17a2d4 100644 --- a/app/api/v1/routes/sensor_variables/sensor_variables.py +++ b/app/api/v1/routes/sensor_variables/sensor_variables.py @@ -5,11 +5,12 @@ from app.db.repositories.sensor_repository import SensorRepository from app.db.session import get_db +from typing import List router = APIRouter(prefix="/sensor_variables", tags=["sensor_variables"]) @router.get("") -async def list_sensor_variables(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[str]: +async def list_sensor_variables(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> List[str]: sensor_repository = SensorRepository(db) return sensor_repository.list_sensor_variables() diff --git a/app/api/v1/schemas/campaign.py b/app/api/v1/schemas/campaign.py index 1c80d3c..7259ad4 100644 --- a/app/api/v1/schemas/campaign.py +++ b/app/api/v1/schemas/campaign.py @@ -8,41 +8,41 @@ class CampaignsIn(BaseModel): name: str - contact_name: str | None = None - contact_email: str | None = None - description: str | None = None - start_date: datetime | None = None - end_date: datetime | None = None + contact_name: Optional[str] = None + contact_email: Optional[str] = None + description: Optional[str] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None allocation: str class CampaignCreateResponse(BaseModel): id: int class Location(BaseModel): - bbox_west: float | None = None - bbox_east: float | None = None - bbox_south: float | None = None - bbox_north: float | None = None + bbox_west: Optional[float] = None + bbox_east: Optional[float] = None + bbox_south: Optional[float] = None + bbox_north: Optional[float] = None class SummaryListCampaigns(BaseModel): - sensor_types: List[str] | None = None - variable_names: List[str] | None = None + sensor_types: Optional[List[str]] = None + variable_names: Optional[List[str]] = None class ListCampaignsResponseItem(BaseModel): id: int name: str - location: Location | None = None - description: str | None = None - contact_name: str | None = None - contact_email: str | None = None - start_date: datetime | None = None - end_date: datetime | None = None - allocation: str | None = None + location: Optional[Location] = None + description: Optional[str] = None + contact_name: Optional[str] = None + contact_email: Optional[str] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + allocation: Optional[str] = None summary: SummaryListCampaigns geometry: dict = Field(default_factory=dict, nullable=True) # type: ignore[call-overload,type-arg] class ListCampaignsResponsePagination(BaseModel): - items: list[ListCampaignsResponseItem] + items: List[ListCampaignsResponseItem] total: int page: int size: int @@ -57,13 +57,13 @@ class SummaryGetCampaign(BaseModel): class GetCampaignResponse(BaseModel): id: int name: str - description: str | None = None - contact_name: str | None = None - contact_email: str | None = None - start_date: datetime | None = None - end_date: datetime | None = None + description: Optional[str] = None + contact_name: Optional[str] = None + contact_email: Optional[str] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None allocation: str - location: Location | None = None + location: Optional[Location] = None summary: SummaryGetCampaign geometry: dict = Field(default_factory=dict, nullable=True) # type: ignore[call-overload,type-arg] stations: list[StationsListResponseItem] = [] diff --git a/app/api/v1/schemas/measurement.py b/app/api/v1/schemas/measurement.py index 820d1f2..540ac35 100644 --- a/app/api/v1/schemas/measurement.py +++ b/app/api/v1/schemas/measurement.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional, List +from typing import Optional, List, Union from geoalchemy2 import Geometry from geojson_pydantic import Point @@ -28,13 +28,13 @@ class MeasurementItem(BaseModel): value: float geometry: Point collectiontime: datetime - sensorid: int | None = None - variablename: str | None = None # modified - variabletype: str | None = None - description: str | None = None + sensorid: Optional[int] = None + variablename: Optional[str] = None # modified + variabletype: Optional[str] = None + description: Optional[str] = None class ListMeasurementsResponsePagination(BaseModel): - items: list[MeasurementItem] + items: List[MeasurementItem] total: int page: int size: int @@ -43,7 +43,7 @@ class ListMeasurementsResponsePagination(BaseModel): max_value: float average_value: float downsampled: bool - downsampled_total: int | None = None + downsampled_total: Optional[int] = None class AggregatedMeasurement(BaseModel): measurement_time: datetime diff --git a/app/api/v1/schemas/sensor.py b/app/api/v1/schemas/sensor.py index 51ac517..86b5c7e 100644 --- a/app/api/v1/schemas/sensor.py +++ b/app/api/v1/schemas/sensor.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Union from pydantic import BaseModel @@ -8,48 +8,48 @@ # Pydantic model for incoming sensor data class SensorIn(BaseModel): - alias: str | float + alias: Union[str, float] description: Optional[str] = None postprocess: Optional[bool] = True postprocessscript: Optional[str] = None units: Optional[str] = None - variablename: str | None = None + variablename: Optional[str] = None class SensorCreateResponse(BaseModel): id: int class SensorStatistics(BaseModel): - max_value: float | None = None - min_value: float | None = None - avg_value: float | None = None - stddev_value: float | None = None - percentile_90: float | None = None - percentile_95: float | None = None - percentile_99: float | None = None - count: int | None = None - first_measurement_value: float | None = None - first_measurement_collectiontime: datetime | None = None - last_measurement_time: datetime | None = None - last_measurement_value: float | None = None - stats_last_updated: datetime | None = None + max_value: Optional[float] = None + min_value: Optional[float] = None + avg_value: Optional[float] = None + stddev_value: Optional[float] = None + percentile_90: Optional[float] = None + percentile_95: Optional[float] = None + percentile_99: Optional[float] = None + count: Optional[int] = None + first_measurement_value: Optional[float] = None + first_measurement_collectiontime: Optional[datetime] = None + last_measurement_time: Optional[datetime] = None + last_measurement_value: Optional[float] = None + stats_last_updated: Optional[datetime] = None class SensorItem(BaseModel): id: int - alias: str | None = None - description: str | None = None - postprocess: bool | None = True - postprocessscript: str | None = None - units: str | None = None - variablename: str | None = None - statistics: SensorStatistics | None = None + alias: Optional[str] = None + description: Optional[str] = None + postprocess: Optional[bool] = True + postprocessscript: Optional[str] = None + units: Optional[str] = None + variablename: Optional[str] = None + statistics: Optional[SensorStatistics] = None class ListSensorsResponse(SensorItem): pass class GetSensorResponse(SensorItem): - statistics: SensorStatistics | None = None + statistics: Optional[SensorStatistics] = None # Pydantic model for incoming sensor and measurement data class SensorAndMeasurementIn(BaseModel): @@ -58,7 +58,7 @@ class SensorAndMeasurementIn(BaseModel): class ListSensorsResponsePagination(BaseModel): - items: list[SensorItem] + items: List[SensorItem] total: int page: int size: int @@ -71,7 +71,7 @@ class SensorUpdate(BaseModel): postprocess: Optional[bool] = True postprocessscript: Optional[str] = None units: Optional[str] = None - variablename: Optional[str] | None = None + variablename: Optional[str] = None class ForceUpdateSensorStatisticsResponse(BaseModel): @@ -81,4 +81,4 @@ class ForceUpdateSensorStatisticsResponse(BaseModel): class UpdateSensorStatisticsResponse(BaseModel): sensor_id: int - updated: bool \ No newline at end of file + updated: bool diff --git a/app/api/v1/schemas/station.py b/app/api/v1/schemas/station.py index 95e2070..87b1926 100644 --- a/app/api/v1/schemas/station.py +++ b/app/api/v1/schemas/station.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import Any, Optional, List +from typing import Any, Optional, List, Union from pydantic import BaseModel, Field from app.api.v1.schemas.sensor import SensorItem @@ -14,21 +14,21 @@ class StationType(str, Enum): class StationCreate(BaseModel): name: str - description: str | None = None - contact_name: str | None = None - contact_email: str | None = None - active: bool | None = True + description: Optional[str] = None + contact_name: Optional[str] = None + contact_email: Optional[str] = None + active: Optional[bool] = True start_date: datetime station_type: StationType = StationType.STATIC class StationItem(BaseModel): id: int name: str - description: str | None = None - contact_name: str | None = None - contact_email: str | None = None - active: bool | None = None - start_date: datetime | None = None + description: Optional[str] = None + contact_name: Optional[str] = None + contact_email: Optional[str] = None + active: Optional[bool] = None + start_date: Optional[datetime] = None geometry: dict = Field(default_factory=dict, nullable=True) # type: ignore[call-overload,type-arg] class StationItemWithSummary(StationItem): @@ -37,7 +37,7 @@ class StationItemWithSummary(StationItem): sensor_variables: List[str] class GetStationResponse(StationItem): - sensors: List[SensorItem] | None = None + sensors: Optional[List[SensorItem]] = None class ListStationsResponsePagination(BaseModel): items: List[StationItemWithSummary] @@ -48,8 +48,8 @@ class ListStationsResponsePagination(BaseModel): class SensorSummaryForStations(BaseModel): id: int - variable_name: str | None = None - measurement_unit: str | None = None + variable_name: Optional[str] = None + measurement_unit: Optional[str] = None class StationsListResponseItem(StationItem): start_date: datetime @@ -59,9 +59,9 @@ class StationsListResponseItem(StationItem): class StationUpdate(BaseModel): name: Optional[str] = None - description: Optional[str] | None = None - contact_name: Optional[str] | None = None - contact_email: Optional[str] | None = None - active: Optional[bool] | None = None - start_date: Optional[datetime] | None = None - station_type: Optional[StationType] | None = None \ No newline at end of file + description: Optional[str] = None + contact_name: Optional[str] = None + contact_email: Optional[str] = None + active: Optional[bool] = None + start_date: Optional[datetime] = None + station_type: Optional[StationType] = None \ No newline at end of file diff --git a/app/api/v1/schemas/upload_csv_validators.py b/app/api/v1/schemas/upload_csv_validators.py index 80656e0..0316ba2 100644 --- a/app/api/v1/schemas/upload_csv_validators.py +++ b/app/api/v1/schemas/upload_csv_validators.py @@ -1,5 +1,6 @@ # type: ignore from datetime import datetime +from typing import Optional from pydantic import ( BaseModel, Field, @@ -12,11 +13,11 @@ class SensorCSV(BaseModel): alias: str - variablename: str | None = Field(alias='BestGuessFormula', default=None) - postprocess: bool | None = Field(default=True) - postprocessscript: str | None = Field(default=None) - description: str | None = Field(default=None) - units: str | None = Field(default=None) + variablename: Optional[str] = Field(alias='BestGuessFormula', default=None) + postprocess: Optional[bool] = Field(default=True) + postprocessscript: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + units: Optional[str] = Field(default=None) @field_validator('alias', mode="before") @classmethod diff --git a/app/api/v1/schemas/user.py b/app/api/v1/schemas/user.py index e90caec..953f78f 100644 --- a/app/api/v1/schemas/user.py +++ b/app/api/v1/schemas/user.py @@ -1,8 +1,9 @@ +from typing import Optional from pydantic import BaseModel class User(BaseModel): username: str - email: str | None = None - full_name: str | None = None - disabled: bool | None = None + email: Optional[str] = None + full_name: Optional[str] = None + disabled: Optional[bool] = None diff --git a/app/db/models/sensor.py b/app/db/models/sensor.py index f8eb196..809f3e8 100644 --- a/app/db/models/sensor.py +++ b/app/db/models/sensor.py @@ -24,6 +24,6 @@ class Sensor(Base): # relationships station: Mapped["Station"] = relationship("Station", back_populates="sensors") - measurements: Mapped[List["Measurement"]] = relationship("Measurement", back_populates="sensor", lazy="dynamic") + measurements: Mapped[List["Measurement"]] = relationship("Measurement", back_populates="sensor", lazy="dynamic", cascade="all, delete-orphan") upload_file_event: Mapped["UploadFileEvent"] = relationship("UploadFileEvent") - statistics: Mapped["SensorStatistics"] = relationship("SensorStatistics", back_populates="sensor") + statistics: Mapped["SensorStatistics"] = relationship("SensorStatistics", back_populates="sensor", cascade="all, delete-orphan") diff --git a/app/db/repositories/campaign_repository.py b/app/db/repositories/campaign_repository.py index 4bcc8de..19a1394 100644 --- a/app/db/repositories/campaign_repository.py +++ b/app/db/repositories/campaign_repository.py @@ -1,6 +1,6 @@ from datetime import datetime import json -from typing import Union +from typing import Union, List, Optional, Tuple from sqlalchemy.orm import Session, joinedload from sqlalchemy import func, select, or_ @@ -31,7 +31,7 @@ def create_campaign(self, request: CampaignsIn) -> Campaign: self.db.refresh(db_campaign) return db_campaign - def get_campaign(self, id: int) -> Campaign | None: + def get_campaign(self, id: int) -> Optional[Campaign]: stmt = ( select(Campaign) .options( @@ -58,14 +58,14 @@ def get_campaign(self, id: int) -> Campaign | None: def get_campaigns_and_summary( self, - allocations: list[str] | None, - bbox: str | None, - start_date: datetime | None, - end_date: datetime | None, - sensor_variables: list[str] | None, + allocations: Optional[List[str]], + bbox: Optional[str], + start_date: Optional[datetime], + end_date: Optional[datetime], + sensor_variables: Optional[List[str]], page: int = 1, limit: int = 20, - ) -> tuple[list[tuple[Campaign, int, int, list[str | None], list[str | None], str | None]], int]: + ) -> Tuple[List[Tuple[Campaign, int, int, List[Optional[str]], List[Optional[str]], Optional[str]]], int]: # Base campaign query query = self.db.query( Campaign, @@ -132,11 +132,11 @@ def count_sensors(self, campaign_id: int) -> int: stations = self.db.query(Station).filter(Station.campaignid == campaign_id).all() return sum(len(station.sensors) for station in stations) - def get_sensor_types(self, campaign_id: int) -> list[str]: + def get_sensor_types(self, campaign_id: int) -> List[str]: stations = self.db.query(Station).filter(Station.campaignid == campaign_id).all() return list(set(sensor.alias for station in stations for sensor in station.sensors)) - def get_sensor_variables(self, campaign_id: int) -> list[str]: + def get_sensor_variables(self, campaign_id: int) -> List[str]: stations = self.db.query(Station).filter(Station.campaignid == campaign_id).all() return list(set(sensor.variablename for station in stations for sensor in station.sensors)) @@ -145,7 +145,7 @@ def delete_campaign_stations(self, campaign_id: int) -> bool: self.db.commit() return True - def update_campaign(self, campaign_id: int, request: Union[CampaignsIn, CampaignUpdate], partial: bool = False) -> Campaign | None: + def update_campaign(self, campaign_id: int, request: Union[CampaignsIn, CampaignUpdate], partial: bool = False) -> Optional[Campaign]: db_campaign = self.db.query(Campaign).filter(Campaign.campaignid == campaign_id).first() if not db_campaign: return None diff --git a/app/db/repositories/measurement_repository.py b/app/db/repositories/measurement_repository.py index 0e3010c..dbd4953 100644 --- a/app/db/repositories/measurement_repository.py +++ b/app/db/repositories/measurement_repository.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List +from typing import List, Optional, Tuple, Union import typing from sqlalchemy.orm import Session @@ -35,21 +35,21 @@ def create_measurement(self, request: MeasurementIn, sensor_id: int) -> Measurem self.db.refresh(db_measurement) return db_measurement - def get_measurement(self, measurement_id: int) -> Measurement | None: + def get_measurement(self, measurement_id: int) -> Optional[Measurement]: return self.db.query(Measurement).get(measurement_id) def list_measurements( self, - sensor_id: int | None = None, - start_date: datetime | None = None, - end_date: datetime | None = None, - min_value: float | None = None, - max_value: float | None = None, - variable_name: str | None = None, + sensor_id: Optional[int] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + min_value: Optional[float] = None, + max_value: Optional[float] = None, + variable_name: Optional[str] = None, page: int = 1, limit: int = 20, - ) -> tuple[ - list[tuple[Measurement, str]], int, float | None, float | None, float | None + ) -> Tuple[ + List[Tuple[Measurement, str]], int, Optional[float], Optional[float], Optional[float] ]: query = self.db.query( Measurement, func.ST_AsGeoJSON(Measurement.geometry).label("geometry") @@ -150,10 +150,10 @@ def get_measurements_with_confidence_intervals( sensor_id: int, interval: str = "hour", interval_value: int = 1, - start_date: datetime | None = None, - end_date: datetime | None = None, - min_value: float | None = None, - max_value: float | None = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + min_value: Optional[float] = None, + max_value: Optional[float] = None, ) -> List[AggregatedMeasurement]: stmt = text( @@ -181,7 +181,7 @@ def get_measurements_with_confidence_intervals( return measurements - def get_latest_measurement_by_sensor_id(self, sensor_id: int) -> Measurement | None: + def get_latest_measurement_by_sensor_id(self, sensor_id: int) -> Optional[Measurement]: return ( self.db.query(Measurement) .filter(Measurement.sensorid == sensor_id) @@ -191,14 +191,13 @@ def get_latest_measurement_by_sensor_id(self, sensor_id: int) -> Measurement | N def update_measurement( self, measurement_id: int, request: MeasurementUpdate, partial: bool = False - ) -> Measurement | None: + ) -> Optional[Measurement]: db_measurement = ( self.db.query(Measurement) .filter(Measurement.measurementid == measurement_id) .first() ) - if not db_measurement: return None @@ -253,8 +252,8 @@ def get_measurements_by_campaign_chunked( self, campaign_id: int, chunk_size: int = 1000, - start_date: datetime | None = None, - end_date: datetime | None = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, ) -> "typing.Iterator[List[typing.Tuple[Measurement, str]]]": """Generator that yields measurements for a campaign in chunks.""" from app.db.models.sensor import Sensor @@ -328,8 +327,8 @@ def get_measurements_by_station_chunked( self, station_id: int, chunk_size: int = 1000, - start_date: datetime | None = None, - end_date: datetime | None = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, ) -> "typing.Iterator[List[typing.Tuple[Measurement, str]]]": """Generator that yields measurements for a station in chunks.""" from app.db.models.sensor import Sensor @@ -369,8 +368,8 @@ def get_measurements_with_coordinates_by_station_chunked( self, station_id: int, chunk_size: int = 1000, - start_date: datetime | None = None, - end_date: datetime | None = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, ) -> "typing.Iterator[List[typing.Tuple[datetime, float, float, str, float]]]": """Generator that yields measurements with coordinates for a station in chunks. @@ -420,8 +419,8 @@ def get_measurements_pivot_by_station_chunked( self, station_id: int, chunk_size: int = 1000, - start_date: datetime | None = None, - end_date: datetime | None = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, ) -> "typing.Iterator[List[typing.Dict[str, typing.Any]]]": """Generator that yields pre-grouped measurements for a station in chunks. diff --git a/app/db/repositories/sensor_repository.py b/app/db/repositories/sensor_repository.py index 0b57a0e..c9a28cb 100644 --- a/app/db/repositories/sensor_repository.py +++ b/app/db/repositories/sensor_repository.py @@ -59,12 +59,12 @@ def create_sensor(self, request: SensorIn, station_id: int) -> Sensor: self.db.refresh(db_sensor) return db_sensor - def create_sensors(self, sensors: list[Sensor]) -> list[Sensor]: + def create_sensors(self, sensors: List[Sensor]) -> List[Sensor]: self.db.add_all(sensors) self.db.commit() return sensors - def get_sensor(self, sensor_id: int) -> GetSensorResponse | None: + def get_sensor(self, sensor_id: int) -> Optional[GetSensorResponse]: stmt = ( select(Sensor, SensorStatistics) .outerjoin(SensorStatistics, Sensor.sensorid == SensorStatistics.sensorid) @@ -132,7 +132,7 @@ def delete_sensor_measurements(self, sensor_id: int) -> None: self.db.query(Measurement).filter(Measurement.sensorid == sensor_id).delete() self.db.commit() - def get_sort_column(self, sort_by: SortField) -> Column[Any] | None: + def get_sort_column(self, sort_by: SortField) -> Optional[Column[Any]]: if sort_by.value in [ SortField.ALIAS.value, SortField.DESCRIPTION.value, @@ -164,14 +164,14 @@ def get_sensors_by_station_id( station_id: int, page: int = 1, limit: int = 20, - variable_name: str | None = None, - units: str | None = None, - alias: str | None = None, - description_contains: str | None = None, - postprocess: bool | None = None, + variable_name: Optional[str] = None, + units: Optional[str] = None, + alias: Optional[str] = None, + description_contains: Optional[str] = None, + postprocess: Optional[bool] = None, sort_by: Optional[SortField] = None, sort_order: str = "asc", - ) -> Tuple[list[Row[Tuple[Sensor, SensorStatistics]]], int]: + ) -> Tuple[List[Row[Tuple[Sensor, SensorStatistics]]], int]: count_stmt = ( select(func.count()) .select_from(Sensor) @@ -225,7 +225,7 @@ def get_sensors( limit: int = 20, sort_by: Optional[SortField] = None, sort_order: str = "asc", - ) -> tuple[list[Row[Tuple[Sensor, SensorStatistics]]], int]: + ) -> Tuple[List[Row[Tuple[Sensor, SensorStatistics]]], int]: stmt = select(Sensor, SensorStatistics).outerjoin( SensorStatistics, Sensor.sensorid == SensorStatistics.sensorid ) @@ -265,12 +265,12 @@ def delete_sensor(self, sensor_id: int) -> bool: return True return False - def list_sensor_variables(self) -> list[str]: + def list_sensor_variables(self) -> List[str]: return [row[0] for row in self.db.query(Sensor.variablename).distinct().all()] def get_sensor_by_alias_and_station_id( self, alias: str, station_id: int - ) -> Sensor | None: + ) -> Optional[Sensor]: return ( self.db.query(Sensor) .filter(Sensor.alias == alias, Sensor.stationid == station_id) @@ -279,8 +279,7 @@ def get_sensor_by_alias_and_station_id( def update_sensor( self, sensor_id: int, request: SensorUpdate, partial: bool = False - ) -> Sensor | None: - + ) -> Optional[Sensor]: db_station = self.db.query(Sensor).filter(Sensor.sensorid == sensor_id).first() if not db_station: diff --git a/app/db/repositories/station_repository.py b/app/db/repositories/station_repository.py index 8bfea3d..34549fe 100644 --- a/app/db/repositories/station_repository.py +++ b/app/db/repositories/station_repository.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional, Tuple, Union from sqlalchemy.orm import Session @@ -30,7 +30,7 @@ def create_station(self, request: StationCreate, campaign_id: int) -> Station: self.db.refresh(db_station) return db_station - def get_station(self, station_id: int) -> Station | None: + def get_station(self, station_id: int) -> Optional[Station]: # Query the station with its sensors and convert geometry to GeoJSON result = self.db.query( Station, @@ -61,10 +61,10 @@ def get_station(self, station_id: int) -> Station | None: return Station(**station_dict) return None - def get_stations_by_campaign_id(self, campaign_id: int, page: int = 1, limit: int = 20) -> list[Station]: + def get_stations_by_campaign_id(self, campaign_id: int, page: int = 1, limit: int = 20) -> List[Station]: return self.db.query(Station).filter(Station.campaignid == campaign_id).offset((page - 1) * limit).limit(limit).all() - def list_stations_and_summary(self, campaign_id: int, page: int = 1, limit: int = 20) -> tuple[list[tuple[Station, int, list[str | None], list[str | None], str | None]], int]: + def list_stations_and_summary(self, campaign_id: int, page: int = 1, limit: int = 20) -> Tuple[List[Tuple[Station, int, List[Optional[str]], List[Optional[str]], Optional[str]]], int]: query = self.db.query(Station, func.count(Sensor.sensorid.distinct()).label('sensor_count'), func.array_agg(func.distinct(Sensor.alias)).label('sensor_types'), @@ -82,7 +82,7 @@ def get_stations( start_date: Optional[datetime] = None, page: int = 1, limit: int = 20, - ) -> tuple[list[Station], int]: + ) -> Tuple[List[Station], int]: query = self.db.query(Station) if campaign_id: query = query.filter(Station.campaignid == campaign_id) @@ -107,8 +107,7 @@ def delete_station_sensors(self, station_id: int) -> bool: self.db.commit() return True - def update_station(self, station_id: int, request: StationUpdate, partial: bool = False) -> Station | None: - + def update_station(self, station_id: int, request: StationUpdate, partial: bool = False) -> Optional[Station]: db_station = self.db.query(Station).filter(Station.stationid == station_id).first() if not db_station: diff --git a/app/pytas/http.py b/app/pytas/http.py index c5dcf69..83fddc7 100755 --- a/app/pytas/http.py +++ b/app/pytas/http.py @@ -330,7 +330,7 @@ def project(self, id): else: r.raise_for_status() - def projects_for_user(self, username: str) -> list[PyTASProject]: + def projects_for_user(self, username: str) -> List[PyTASProject]: headers = {"Content-Type": "application/json"} r = requests.get( "{0}/v1/projects/username/{1}".format(self.baseURL, username), @@ -448,7 +448,7 @@ def del_project_user(self, project_id, username): else: raise Exception("Failed to remove user from project", resp["message"]) - def get_project_members(self, project_id: str) -> list[PyTASUser]: + def get_project_members(self, project_id: str) -> List[PyTASUser]: headers = {"Content-Type": "application/json"} r = requests.get( "{0}/v1/projects/{1}/users".format(self.baseURL, project_id), diff --git a/app/pytas/models/schemas.py b/app/pytas/models/schemas.py index c63261f..44db33e 100644 --- a/app/pytas/models/schemas.py +++ b/app/pytas/models/schemas.py @@ -6,10 +6,10 @@ class PyTASUser(BaseModel): id: int username: str - role: str | None = None - firstName: str | None = None - lastName: str | None = None - email: str | None = None + role: Optional[str] = None + firstName: Optional[str] = None + lastName: Optional[str] = None + email: Optional[str] = None class PyTASPi(BaseModel): diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py index 3cfb84e..a663e58 100644 --- a/app/services/campaign_service.py +++ b/app/services/campaign_service.py @@ -5,6 +5,7 @@ from app.api.v1.schemas.campaign import CampaignsIn, CampaignCreateResponse, GetCampaignResponse, ListCampaignsResponseItem, Location, SummaryGetCampaign, SummaryListCampaigns, CampaignUpdate +from typing import List, Optional, Tuple, Union class CampaignService: def __init__(self, campaign_repository: CampaignRepository): self.campaign_repository = campaign_repository @@ -14,14 +15,14 @@ def create_campaign(self, campaign: CampaignsIn) -> CampaignCreateResponse: return CampaignCreateResponse( id=response.campaignid, ) - def update_campaign(self, campaign_id: int, campaign: CampaignsIn) -> CampaignCreateResponse | None: + def update_campaign(self, campaign_id: int, campaign: CampaignsIn) -> Optional[CampaignCreateResponse]: response = self.campaign_repository.update_campaign(campaign_id, campaign) if not response: return None return CampaignCreateResponse( id=response.campaignid, ) - def partial_update_campaign(self, campaign_id: int, campaign: CampaignUpdate) -> CampaignCreateResponse | None: + def partial_update_campaign(self, campaign_id: int, campaign: CampaignUpdate) -> Optional[CampaignCreateResponse]: response = self.campaign_repository.update_campaign(campaign_id, campaign, partial=True) if not response: return None @@ -31,21 +32,21 @@ def partial_update_campaign(self, campaign_id: int, campaign: CampaignUpdate) -> def get_campaigns_with_summary( self, - allocations: list[str] | None = None, - bbox: str | None = None, - start_date: datetime | None = None, - end_date: datetime | None = None, - sensor_variables: list[str] | None = None, + allocations: Optional[List[str]] = None, + bbox: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + sensor_variables: Optional[List[str]] = None, page: int = 1, limit: int = 20, - ) -> tuple[list[ListCampaignsResponseItem], int]: + ) -> Tuple[List[ListCampaignsResponseItem], int]: rows, total_count = self.campaign_repository.get_campaigns_and_summary( allocations, bbox, start_date, end_date, sensor_variables, page, limit ) - items: list[ListCampaignsResponseItem] = [] + items: List[ListCampaignsResponseItem] = [] for row in rows: - sensor_types : list[str | None] = row[3] - variable_names : list[str | None] = row[4] + sensor_types : List[Optional[str]] = row[3] + variable_names : List[Optional[str]] = row[4] item = ListCampaignsResponseItem( id=row[0].campaignid, name=row[0].campaignname, @@ -70,7 +71,7 @@ def get_campaigns_with_summary( items.append(item) return items, total_count - def get_campaign_with_summary(self, campaign_id: int) -> GetCampaignResponse | None: + def get_campaign_with_summary(self, campaign_id: int) -> Optional[GetCampaignResponse]: campaign = self.campaign_repository.get_campaign(campaign_id) if not campaign: return None diff --git a/app/services/export_service.py b/app/services/export_service.py index ddf8c28..e70cd49 100644 --- a/app/services/export_service.py +++ b/app/services/export_service.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Iterator +from typing import Iterator, Optional from app.db.repositories.sensor_repository import SensorRepository from app.db.repositories.measurement_repository import MeasurementRepository @@ -49,8 +49,8 @@ def export_sensors_csv(self, station_id: int) -> Iterator[str]: def export_measurements_csv( self, station_id: int, - start_date: datetime | None = None, - end_date: datetime | None = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, ) -> Iterator[str]: """Export measurements for a station as CSV with streaming support. diff --git a/app/services/measurement_service.py b/app/services/measurement_service.py index 5ddac2f..086e144 100644 --- a/app/services/measurement_service.py +++ b/app/services/measurement_service.py @@ -1,5 +1,6 @@ from datetime import datetime import json +from typing import List, Optional from app.api.v1.schemas.measurement import AggregatedMeasurement, MeasurementCreateResponse, MeasurementIn, MeasurementItem, ListMeasurementsResponsePagination, MeasurementUpdate from app.db.repositories.measurement_repository import MeasurementRepository from app.utils.lttb import lttb @@ -9,11 +10,11 @@ class MeasurementService: def __init__(self, measurement_repository: MeasurementRepository): self.measurement_repository = measurement_repository - def list_measurements(self, sensor_id: int, start_date: datetime | None, end_date: datetime | None, min_value: float | None, max_value: float | None, page: int = 1, limit: int = 20, downsample_threshold: int | None = None) -> ListMeasurementsResponsePagination: + def list_measurements(self, sensor_id: int, start_date: Optional[datetime], end_date: Optional[datetime], min_value: Optional[float], max_value: Optional[float], page: int = 1, limit: int = 20, downsample_threshold: Optional[int] = None) -> ListMeasurementsResponsePagination: rows, total_count, stats_min_value, stats_max_value, stats_average_value = self.measurement_repository.list_measurements(sensor_id=sensor_id, start_date=start_date, end_date=end_date, min_value=min_value, max_value=max_value, page=page, limit=limit) # Convert rows to MeasurementItem objects - measurements : list[MeasurementItem] = [] + measurements : List[MeasurementItem] = [] for row in rows: if row[1] is not None: measurements.append(MeasurementItem( @@ -52,17 +53,16 @@ def list_measurements(self, sensor_id: int, start_date: datetime | None, end_dat average_value=stats_average_value if stats_average_value is not None else 0 ) - def get_measurements_with_confidence_intervals(self, sensor_id: int, interval: str, interval_value: int, start_date: datetime | None, end_date: datetime | None, min_value: float | None, max_value: float | None) -> list[AggregatedMeasurement]: + def get_measurements_with_confidence_intervals(self, sensor_id: int, interval: str, interval_value: int, start_date: Optional[datetime], end_date: Optional[datetime], min_value: Optional[float], max_value: Optional[float]) -> List[AggregatedMeasurement]: return self.measurement_repository.get_measurements_with_confidence_intervals(sensor_id=sensor_id, interval=interval, interval_value=interval_value, start_date=start_date, end_date=end_date, min_value=min_value, max_value=max_value) - - def update_measurement(self, measurement_id: int, measurement: MeasurementUpdate) -> MeasurementCreateResponse | None: + def update_measurement(self, measurement_id: int, measurement: MeasurementUpdate) -> Optional[MeasurementCreateResponse]: response = self.measurement_repository.update_measurement(measurement_id, measurement) if not response: return None return MeasurementCreateResponse( id=response.sensorid, ) - def partial_update_measurement(self, measurement_id: int, measurement: MeasurementUpdate) -> MeasurementCreateResponse | None: + def partial_update_measurement(self, measurement_id: int, measurement: MeasurementUpdate) -> Optional[MeasurementCreateResponse]: response = self.measurement_repository.update_measurement(measurement_id, measurement, partial=True) if not response: return None diff --git a/app/services/project_service.py b/app/services/project_service.py index 1119104..c9b9307 100644 --- a/app/services/project_service.py +++ b/app/services/project_service.py @@ -2,6 +2,7 @@ from app.pytas.models.schemas import PyTASUser, PyTASProject from app.core.config import get_settings +from typing import List settings = get_settings() class ProjectService: @@ -14,7 +15,7 @@ def __init__(self) -> None: }, ) - def get_projects_for_user(self, username: str) -> list[PyTASProject]: + def get_projects_for_user(self, username: str) -> List[PyTASProject]: active_projects = [] for p in self.client.projects_for_user(username=username): if p.allocations[0].status != "Inactive": @@ -22,5 +23,5 @@ def get_projects_for_user(self, username: str) -> list[PyTASProject]: return active_projects - def get_project_members(self, project_id: str) -> list[PyTASUser]: + def get_project_members(self, project_id: str) -> List[PyTASUser]: return self.client.get_project_members(project_id=project_id) # type: ignore[no-any-return] diff --git a/app/services/sensor_service.py b/app/services/sensor_service.py index f6e8b4e..41fbe9d 100644 --- a/app/services/sensor_service.py +++ b/app/services/sensor_service.py @@ -34,21 +34,21 @@ def create_sensor(self, sensor: SensorIn, station_id: int) -> GetSensorResponse: variablename=response.variablename, statistics=None ) - def update_sensor(self, sensor_id: int, sensor: SensorUpdate) -> SensorCreateResponse | None: + def update_sensor(self, sensor_id: int, sensor: SensorUpdate) -> Optional[SensorCreateResponse]: response = self.sensor_repository.update_sensor(sensor_id, sensor) if not response: return None return SensorCreateResponse( id=response.sensorid, ) - def partial_update_sensor(self, sensor_id: int, sensor: SensorUpdate) -> SensorCreateResponse | None: + def partial_update_sensor(self, sensor_id: int, sensor: SensorUpdate) -> Optional[SensorCreateResponse]: response = self.sensor_repository.update_sensor(sensor_id, sensor, partial=True) if not response: return None return SensorCreateResponse( id=response.sensorid, ) - def get_sensor(self, sensor_id: int) -> GetSensorResponse | None: + def get_sensor(self, sensor_id: int) -> Optional[GetSensorResponse]: return self.sensor_repository.get_sensor(sensor_id) def get_sensors( @@ -106,11 +106,11 @@ def get_sensors_by_station_id( station_id: int, page: int = 1, limit: int = 20, - variable_name: str | None = None, - units: str | None = None, - alias: str | None = None, - description_contains: str | None = None, - postprocess: bool | None = None, + variable_name: Optional[str] = None, + units: Optional[str] = None, + alias: Optional[str] = None, + description_contains: Optional[str] = None, + postprocess: Optional[bool] = None, sort_by: Optional[SortField] = None, sort_order: str = "asc" ) -> Tuple[List[SensorItem], int]: diff --git a/app/services/station_service.py b/app/services/station_service.py index 28e92a2..1f76f64 100644 --- a/app/services/station_service.py +++ b/app/services/station_service.py @@ -4,33 +4,33 @@ from app.db.repositories.station_repository import StationRepository +from typing import List, Optional, Tuple, Union class StationService: def __init__(self, station_repository: StationRepository): self.station_repository = station_repository def create_station(self, station: StationCreate, campaign_id: int) -> StationCreateResponse: return StationCreateResponse(id=self.station_repository.create_station(station, campaign_id).stationid) - - def update_station(self, station_id: int, station: StationUpdate) -> StationCreateResponse | None: + def update_station(self, station_id: int, station: StationUpdate) -> Optional[StationCreateResponse]: response = self.station_repository.update_station(station_id, station) if not response: return None return StationCreateResponse( id=response.campaignid, ) - def partial_update_station(self, station_id: int, station: StationUpdate) -> StationCreateResponse | None: + def partial_update_station(self, station_id: int, station: StationUpdate) -> Optional[StationCreateResponse]: response = self.station_repository.update_station(station_id, station, partial=True) if not response: return None return StationCreateResponse( id=response.campaignid, ) - def get_stations_with_summary(self, campaign_id: int, page: int = 1, limit: int = 20) -> tuple[list[StationItemWithSummary], int]: + def get_stations_with_summary(self, campaign_id: int, page: int = 1, limit: int = 20) -> Tuple[List[StationItemWithSummary], int]: rows, total_count = self.station_repository.list_stations_and_summary(campaign_id, page, limit) - stations : list[StationItemWithSummary] = [] + stations : List[StationItemWithSummary] = [] for row in rows: - sensor_types : list[str | None] = row[2] - sensor_variables : list[str | None] = row[3] + sensor_types : List[Optional[str]] = row[2] + sensor_variables : List[Optional[str]] = row[3] geometry = json.loads(row[4]) if row[4] else {} station = StationItemWithSummary( id=row[0].stationid, @@ -45,7 +45,7 @@ def get_stations_with_summary(self, campaign_id: int, page: int = 1, limit: int return stations, total_count - def get_station(self, station_id: int) -> GetStationResponse | None: + def get_station(self, station_id: int) -> Optional[GetStationResponse]: row = self.station_repository.get_station(station_id) geometry = {} if row: diff --git a/app/utils/lttb.py b/app/utils/lttb.py index de233f6..dcff2fa 100644 --- a/app/utils/lttb.py +++ b/app/utils/lttb.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Union from datetime import datetime from app.api.v1.schemas.measurement import MeasurementItem @@ -17,8 +17,7 @@ def calculate_triangle_area(p1: MeasurementItem, p2: MeasurementItem, p3: Measur y2 = p2.value y3 = p3.value - # Area = |(x1(y2-y3) + x2(y3-y1) + x3(y1-y2))/2| - area = abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2.0) + # Area = |(x1(y2-y3) + x2(y3-y1) + x3(y1-y2))/Union[2, area] = abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2.0) return area diff --git a/app/utils/upload_csv.py b/app/utils/upload_csv.py index a88ecd3..8863cd0 100644 --- a/app/utils/upload_csv.py +++ b/app/utils/upload_csv.py @@ -5,7 +5,7 @@ from sqlalchemy.dialects.postgresql import insert from starlette.formparsers import MultiPartParser from fastapi import HTTPException, UploadFile -from pandantic import Pandantic +from pandantic import BaseModel as PandanticBaseModel from geoalchemy2 import WKTElement from sqlalchemy.orm import Session from app.db.models.measurement import Measurement @@ -13,13 +13,14 @@ from app.db.models.sensor import Sensor from app.api.v1.schemas.sensor import SensorIn +from typing import Dict, List, Union # Constants MultiPartParser.spool_max_size = 500 * 1024 * 1024 BATCH_SIZE = 10000 DEFAULT_VARIABLE_NAME = 'No BestGuess Formula' -def process_batch(batch: list[dict[str, int | datetime | float | WKTElement]], session: Session) -> int: +def process_batch(batch: List[Dict[str, Union[int, datetime, float, WKTElement]]], session: Session) -> int: """Process a batch of measurements and insert to database.""" if not batch: return 0 @@ -33,14 +34,14 @@ def process_batch(batch: list[dict[str, int | datetime | float | WKTElement]], s batch.clear() return inserted_count -def process_sensors_file(file: UploadFile, station_id: int, upload_event_id: int, session: Session) -> dict[str, int]: +def process_sensors_file(file: UploadFile, station_id: int, upload_event_id: int, session: Session) -> Dict[str, int]: """Process the sensors CSV file and return a mapping of aliases to sensor IDs.""" # Read CSV using pandas sensor_repository = SensorRepository(session) df_sensors = pd.read_csv(file.file, keep_default_na=False, na_values=[]) - sensor_maps : list[Sensor]= [] - existing_sensors : list[Sensor]= [] - validator = Pandantic(schema=SensorIn) + sensor_maps : List[Sensor]= [] + existing_sensors : List[Sensor]= [] + validator = PandanticBaseModel(schema=SensorIn) try: validator.validate(dataframe=df_sensors, errors="raise") @@ -71,7 +72,7 @@ def process_sensors_file(file: UploadFile, station_id: int, upload_event_id: int Sensor.alias, Sensor.sensorid ).filter(Sensor.upload_file_events_id == upload_event_id).all() - response: dict[str, int] = {} + response: Dict[str, int] = {} for el in alias_to_sensorid: if el.alias is not None: response[el.alias] = el.sensorid @@ -88,7 +89,7 @@ def create_measurement_dict( geometry: WKTElement, sensor_id: int, upload_event_id: int -) -> dict[str, int | datetime | float | WKTElement]: +) -> Dict[str, Union[int, datetime, float, WKTElement]]: """Create a measurement dictionary with all required fields.""" return { 'stationid': station_id, @@ -102,7 +103,7 @@ def create_measurement_dict( def process_measurements_file( file: UploadFile, station_id: int, - alias_to_sensorid_map: dict[str, int], + alias_to_sensorid_map: Dict[str, int], upload_event_id: int, session: Session ) -> tuple[int, list[str]]: @@ -158,7 +159,7 @@ def process_measurements_file( return total_measurements, errors -def update_sensor_statistics(sensor_repository: SensorRepository, alias_to_sensorid_map: dict[str, int]) -> None: +def update_sensor_statistics(sensor_repository: SensorRepository, alias_to_sensorid_map: Dict[str, int]) -> None: """Update statistics for all sensors.""" for sensor_id in alias_to_sensorid_map.values(): sensor_repository.delete_sensor_statistics(sensor_id) diff --git a/fix_union_syntax.py b/fix_union_syntax.py new file mode 100644 index 0000000..ace93ab --- /dev/null +++ b/fix_union_syntax.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +import os +import re +import glob + +# Find all Python files that might have union syntax issues +def find_python_files(directory): + return glob.glob(f"{directory}/**/*.py", recursive=True) + +# Fix union syntax in a file +def fix_file(filepath): + with open(filepath, 'r') as f: + content = f.read() + + original_content = content + + # Check if file already has typing imports + has_union = 'Union' in content + has_list = 'List' in content + has_dict = 'Dict' in content + has_tuple = 'Tuple' in content + has_optional = 'Optional' in content + + # Find existing typing imports + typing_imports = [] + typing_match = re.search(r'from typing import ([^\n]+)', content) + if typing_match: + existing_imports = [imp.strip() for imp in typing_match.group(1).split(',')] + typing_imports.extend(existing_imports) + + # Add missing imports + needed_imports = set() + if re.search(r'\b\w+\s*\|\s*\w+', content): + needed_imports.add('Union') + if re.search(r'\b\w+\s*\|\s*None', content): + needed_imports.add('Optional') + if re.search(r'\blist\[', content): + needed_imports.add('List') + if re.search(r'\bdict\[', content): + needed_imports.add('Dict') + if re.search(r'\btuple\[', content): + needed_imports.add('Tuple') + + # Add missing imports to the list + for imp in needed_imports: + if imp not in typing_imports: + typing_imports.append(imp) + + # Update typing imports if needed + if typing_match and needed_imports: + new_imports = ', '.join(sorted(set(typing_imports))) + content = content.replace(typing_match.group(0), f'from typing import {new_imports}') + elif needed_imports and not typing_match: + # Add typing import after other imports + import_lines = [] + other_lines = [] + in_imports = True + for line in content.split('\n'): + if in_imports and (line.startswith('from ') or line.startswith('import ') or line.strip() == ''): + import_lines.append(line) + else: + in_imports = False + other_lines.append(line) + + new_imports = ', '.join(sorted(needed_imports)) + import_lines.append(f'from typing import {new_imports}') + content = '\n'.join(import_lines + other_lines) + + # Fix union syntax patterns + # Type | None -> Optional[Type] + content = re.sub(r'(\w+)\s*\|\s*None', r'Optional[\1]', content) + + # Type1 | Type2 -> Union[Type1, Type2] + content = re.sub(r'(\w+)\s*\|\s*(\w+)(?!\s*\])', r'Union[\1, \2]', content) + + # list[Type] -> List[Type] + content = re.sub(r'\blist\[', 'List[', content) + + # dict[Key, Value] -> Dict[Key, Value] + content = re.sub(r'\bdict\[', 'Dict[', content) + + # tuple[...] -> Tuple[...] + content = re.sub(r'\btuple\[', 'Tuple[', content) + + # Only write if content changed + if content != original_content: + with open(filepath, 'w') as f: + f.write(content) + print(f"Fixed: {filepath}") + return True + return False + +# Main execution +if __name__ == "__main__": + directory = "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app" + files = find_python_files(directory) + + fixed_count = 0 + for filepath in files: + if fix_file(filepath): + fixed_count += 1 + + print(f"Fixed {fixed_count} files") \ No newline at end of file diff --git a/tests/api/test_campaign_station_sensors.py b/tests/api/test_campaign_station_sensors.py index 2d30c94..ea0e705 100644 --- a/tests/api/test_campaign_station_sensors.py +++ b/tests/api/test_campaign_station_sensors.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta from unittest.mock import MagicMock, patch -from typing import Tuple, List, Dict, Any, Optional +from typing import Tuple, List, Dict, Any, Optional, Union import pytest import jwt from fastapi.testclient import TestClient @@ -18,7 +18,7 @@ def client() -> TestClient: return TestClient(app) @pytest.fixture -def auth_headers() -> dict[str, str]: +def auth_headers() -> Dict[str, str]: """Create authentication headers with a JWT token""" # Create a test token payload = { @@ -30,7 +30,7 @@ def auth_headers() -> dict[str, str]: return {"Authorization": f"Bearer {token}"} @pytest.fixture -def sample_sensors() -> list[Sensor]: +def sample_sensors() -> List[Sensor]: return [ Sensor( sensorid=1, @@ -65,7 +65,7 @@ def sample_sensors() -> list[Sensor]: ] @pytest.fixture -def sample_statistics() -> list[SensorStatistics]: +def sample_statistics() -> List[SensorStatistics]: return [ SensorStatistics( sensorid=1, @@ -111,19 +111,19 @@ def sample_statistics() -> list[SensorStatistics]: @pytest.fixture -def mock_sensor_repository(sample_sensors: list[Sensor], sample_statistics: list[SensorStatistics]) -> MagicMock: +def mock_sensor_repository(sample_sensors: List[Sensor], sample_statistics: List[SensorStatistics]) -> MagicMock: repository = MagicMock(spec=SensorRepository) def get_sensors_by_station_id_mock( station_id: int, page: int = 1, limit: int = 20, - variable_name: str | None = None, - units: str | None = None, - alias: str | None = None, - description_contains: str | None = None, - postprocess: bool | None = None, - sort_by: SortField | None = None, + variable_name: Optional[str] = None, + units: Optional[str] = None, + alias: Optional[str] = None, + description_contains: Optional[str] = None, + postprocess: Optional[bool] = None, + sort_by: Optional[SortField] = None, sort_order: str = "asc" ) -> Tuple[List[Tuple[Sensor, SensorStatistics]], int]: # Filter sensors based on parameters @@ -189,7 +189,7 @@ def get_sensors_by_station_id_mock( @patch('app.api.v1.routes.campaigns.campaign_station_sensors.SensorRepository') @patch('app.core.config.get_settings') -def test_list_sensors_basic(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: list[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: +def test_list_sensors_basic(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: # Setup mocks mock_repository_class.return_value = mock_sensor_repository mock_settings = MagicMock() @@ -213,7 +213,7 @@ def test_list_sensors_basic(mock_get_settings: MagicMock, mock_repository_class: @patch('app.api.v1.routes.campaigns.campaign_station_sensors.SensorRepository') @patch('app.core.config.get_settings') -def test_list_sensors_with_variable_name_filter(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: list[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: +def test_list_sensors_with_variable_name_filter(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: # Setup mocks mock_repository_class.return_value = mock_sensor_repository mock_settings = MagicMock() @@ -233,7 +233,7 @@ def test_list_sensors_with_variable_name_filter(mock_get_settings: MagicMock, mo @patch('app.api.v1.routes.campaigns.campaign_station_sensors.SensorRepository') @patch('app.core.config.get_settings') -def test_list_sensors_with_units_filter(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: list[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: +def test_list_sensors_with_units_filter(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: # Setup mocks mock_repository_class.return_value = mock_sensor_repository mock_settings = MagicMock() @@ -253,7 +253,7 @@ def test_list_sensors_with_units_filter(mock_get_settings: MagicMock, mock_repos @patch('app.api.v1.routes.campaigns.campaign_station_sensors.SensorRepository') @patch('app.core.config.get_settings') -def test_list_sensors_with_alias_filter(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: list[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: +def test_list_sensors_with_alias_filter(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: # Setup mocks mock_repository_class.return_value = mock_sensor_repository mock_settings = MagicMock() @@ -273,7 +273,7 @@ def test_list_sensors_with_alias_filter(mock_get_settings: MagicMock, mock_repos @patch('app.api.v1.routes.campaigns.campaign_station_sensors.SensorRepository') @patch('app.core.config.get_settings') -def test_list_sensors_with_description_filter(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: list[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: +def test_list_sensors_with_description_filter(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: # Setup mocks mock_repository_class.return_value = mock_sensor_repository mock_settings = MagicMock() @@ -294,7 +294,7 @@ def test_list_sensors_with_description_filter(mock_get_settings: MagicMock, mock @patch('app.api.v1.routes.campaigns.campaign_station_sensors.SensorRepository') @patch('app.core.config.get_settings') -def test_list_sensors_with_postprocess_filter(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: list[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: +def test_list_sensors_with_postprocess_filter(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: # Setup mocks mock_repository_class.return_value = mock_sensor_repository mock_settings = MagicMock() @@ -314,7 +314,7 @@ def test_list_sensors_with_postprocess_filter(mock_get_settings: MagicMock, mock @patch('app.api.v1.routes.campaigns.campaign_station_sensors.SensorRepository') @patch('app.core.config.get_settings') -def test_list_sensors_pagination(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: list[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: +def test_list_sensors_pagination(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: # Setup mocks mock_repository_class.return_value = mock_sensor_repository mock_settings = MagicMock() @@ -346,7 +346,7 @@ def test_list_sensors_pagination(mock_get_settings: MagicMock, mock_repository_c @patch('app.api.v1.routes.campaigns.campaign_station_sensors.SensorRepository') @patch('app.core.config.get_settings') -def test_list_sensors_combined_filters(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: list[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: +def test_list_sensors_combined_filters(mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str]) -> None: # Setup mocks mock_repository_class.return_value = mock_sensor_repository mock_settings = MagicMock() @@ -393,7 +393,7 @@ def test_list_sensors_sort_by_alias_asc( mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, - sample_sensors: list[Sensor], + sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str] ) -> None: @@ -421,7 +421,7 @@ def test_list_sensors_sort_by_alias_desc( mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, - sample_sensors: list[Sensor], + sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str] ) -> None: @@ -449,7 +449,7 @@ def test_list_sensors_sort_by_max_value( mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, - sample_sensors: list[Sensor], + sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str] ) -> None: @@ -477,7 +477,7 @@ def test_list_sensors_sort_with_filters( mock_get_settings: MagicMock, mock_repository_class: MagicMock, client: TestClient, - sample_sensors: list[Sensor], + sample_sensors: List[Sensor], mock_sensor_repository: MagicMock, auth_headers: Dict[str, str] ) -> None: diff --git a/tests/test_campaign_station_sensor_measurement_routes.py b/tests/test_campaign_station_sensor_measurement_routes.py index e5db851..e09fea3 100644 --- a/tests/test_campaign_station_sensor_measurement_routes.py +++ b/tests/test_campaign_station_sensor_measurement_routes.py @@ -2,7 +2,7 @@ from fastapi.testclient import TestClient from unittest.mock import MagicMock, patch from datetime import datetime, timedelta -from typing import List, Dict, Any, Tuple +from typing import List, Dict, Any, Tuple, Optional from unittest.mock import ANY import jwt @@ -21,7 +21,7 @@ def client() -> TestClient: return TestClient(app) @pytest.fixture -def auth_headers() -> dict[str, str]: +def auth_headers() -> Dict[str, str]: """Create authentication headers with a JWT token""" payload = { "sub": "test_user", @@ -80,7 +80,7 @@ def list_measurements_mock( sensor_id: int, page: int, limit: int, start_date: Any, end_date: Any, min_value: Any, max_value: Any, # variable_name is not a param in route # downsample_threshold is handled by service, repo just returns data - ) -> Tuple[List[Tuple[MeasurementModel, str]], int, float | None, float | None, float | None]: + ) -> Tuple[List[Tuple[MeasurementModel, str]], int, Optional[float], Optional[float], Optional[float]]: # Simulate filtering and pagination based on inputs if needed for more complex tests # For now, return sample data paginated_data = sample_measurement_model_data[(page-1)*limit : page*limit] @@ -90,7 +90,7 @@ def list_measurements_mock( repository.get_measurements_with_confidence_intervals.return_value = sample_aggregated_measurements_data # For PUT/PATCH - def update_measurement_mock(measurement_id: int, request: Any, partial: bool = False) -> MeasurementModel | None: + def update_measurement_mock(measurement_id: int, request: Any, partial: bool = False) -> Optional[MeasurementModel]: if measurement_id == 1: # Assume measurement 1 exists updated_model = MeasurementModel( measurementid=measurement_id, diff --git a/tests/test_measurement_service.py b/tests/test_measurement_service.py index d7e4e10..ddf1325 100644 --- a/tests/test_measurement_service.py +++ b/tests/test_measurement_service.py @@ -2,13 +2,14 @@ from geoalchemy2 import Geometry import pytest from datetime import datetime, timedelta +from typing import Optional from geojson_pydantic import Point from app.api.v1.schemas.measurement import MeasurementItem, ListMeasurementsResponsePagination from app.services.measurement_service import MeasurementService from app.db.repositories.measurement_repository import MeasurementRepository # Mock data for testing -def create_mock_measurement(id_value: int, measurement_value: float, collection_time: datetime, geometry: Point | None = None) -> MeasurementItem: +def create_mock_measurement(id_value: int, measurement_value: float, collection_time: datetime, geometry: Optional[Point] = None) -> MeasurementItem: if geometry is None: geometry = {"type": "Point", "coordinates": [10.0, 20.0]}