From cbcb50a97c33a42a33cba684e6a00df956e9d7f0 Mon Sep 17 00:00:00 2001 From: Will Mobley Date: Thu, 2 Oct 2025 11:31:52 -0500 Subject: [PATCH 1/3] Add Delete Funcitonality --- .../v1/routes/campaigns/campaign_stations.py | 22 +++++++++++++++++++ app/db/repositories/station_repository.py | 3 ++- app/services/station_service.py | 8 +++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/api/v1/routes/campaigns/campaign_stations.py b/app/api/v1/routes/campaigns/campaign_stations.py index 14ce833..6c23b1d 100644 --- a/app/api/v1/routes/campaigns/campaign_stations.py +++ b/app/api/v1/routes/campaigns/campaign_stations.py @@ -94,6 +94,28 @@ def delete_sensor( return Response(status_code=204) +@router.delete("/stations/{station_id}", status_code=204) +def delete_station( + campaign_id: int, + station_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") + + # Use station service to delete individual station + station_service = StationService(StationRepository(db)) + deleted_station = station_service.delete_station(station_id) + + if not deleted_station: + raise HTTPException(status_code=404, detail="Station not found") + + return Response(status_code=204) + + + + @router.put("/stations/{station_id}", response_model=StationCreateResponse) def update_station( station_id: int, diff --git a/app/db/repositories/station_repository.py b/app/db/repositories/station_repository.py index 8bfea3d..fa81090 100644 --- a/app/db/repositories/station_repository.py +++ b/app/db/repositories/station_repository.py @@ -95,7 +95,8 @@ def get_stations( return query.offset((page - 1) * limit).limit(limit).all(), total_count def delete_station(self, station_id: int) -> bool: - db_station = self.get_station(station_id) + # Query the station directly from the database session for deletion + db_station = self.db.query(Station).filter(Station.stationid == station_id).first() if db_station: self.db.delete(db_station) self.db.commit() diff --git a/app/services/station_service.py b/app/services/station_service.py index 28e92a2..311cfc9 100644 --- a/app/services/station_service.py +++ b/app/services/station_service.py @@ -74,5 +74,9 @@ def get_station(self, station_id: int) -> GetStationResponse | None: variablename=sensor.variablename, ) for sensor in row.sensors] ) - def delete_station_sensors(self, station_id: int) ->bool: - return self.station_repository.delete_station_sensors(station_id) \ No newline at end of file + def delete_station_sensors(self, station_id: int) -> bool: + return self.station_repository.delete_station_sensors(station_id) + + def delete_station(self, station_id: int) -> bool: + """Delete an individual station by its ID.""" + return self.station_repository.delete_station(station_id) \ No newline at end of file From b2b7a049fa2147355358b859c6cf99ca9adac24f Mon Sep 17 00:00:00 2001 From: Will Mobley Date: Mon, 6 Oct 2025 15:38:58 -0500 Subject: [PATCH 2/3] Update Docker to Handle Publishing. --- .../467dfa27d7ea_add_publishing_fields.py | 68 +++++ app/api/dependencies/auth.py | 20 ++ app/api/v1/main.py | 2 + .../campaign_station_sensor_measurements.py | 60 ++++- .../campaigns/campaign_station_sensors.py | 113 ++++++-- .../v1/routes/campaigns/campaign_stations.py | 77 +++++- app/api/v1/routes/campaigns/permissions.py | 42 +++ app/api/v1/routes/campaigns/root.py | 74 ++++- app/api/v1/schemas/campaign.py | 31 ++- app/api/v1/schemas/measurement.py | 26 +- app/api/v1/schemas/sensor.py | 24 +- app/api/v1/schemas/station.py | 18 +- app/api/v1/schemas/upload_csv_validators.py | 1 + app/db/models/campaign.py | 21 +- app/db/models/measurement.py | 11 +- app/db/models/sensor.py | 16 +- app/db/models/sensor_statistics.py | 24 +- app/db/models/station.py | 16 +- app/db/repositories/campaign_repository.py | 11 +- app/db/repositories/measurement_repository.py | 2 +- app/db/repositories/sensor_repository.py | 23 +- app/db/repositories/station_repository.py | 42 ++- app/pytas/models/schemas.py | 4 +- app/services/campaign_service.py | 12 +- app/services/measurement_service.py | 31 ++- app/services/publishing_service.py | 255 ++++++++++++++++++ app/services/sensor_service.py | 30 ++- app/services/station_service.py | 22 +- fix_optional.py | 77 ++++++ tests/api/test_campaign_station_sensors.py | 12 +- tests/test_measurement_service.py | 3 +- 31 files changed, 995 insertions(+), 173 deletions(-) create mode 100644 alembic/versions/467dfa27d7ea_add_publishing_fields.py create mode 100644 app/api/v1/routes/campaigns/permissions.py create mode 100644 app/services/publishing_service.py create mode 100644 fix_optional.py diff --git a/alembic/versions/467dfa27d7ea_add_publishing_fields.py b/alembic/versions/467dfa27d7ea_add_publishing_fields.py new file mode 100644 index 0000000..ab398a8 --- /dev/null +++ b/alembic/versions/467dfa27d7ea_add_publishing_fields.py @@ -0,0 +1,68 @@ +"""add_publishing_fields + +Revision ID: 467dfa27d7ea +Revises: 778a9dbdeb5e +Create Date: 2025-10-02 12:32:18.268811 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '467dfa27d7ea' +down_revision: Union[str, None] = '778a9dbdeb5e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add publishing status fields to all tables.""" + # Add publishing fields to campaigns table + op.add_column('campaigns', sa.Column('is_published', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('campaigns', sa.Column('published_at', sa.TIMESTAMP(timezone=True), nullable=True)) + + # Add publishing fields to stations table + op.add_column('stations', sa.Column('is_published', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('stations', sa.Column('published_at', sa.TIMESTAMP(timezone=True), nullable=True)) + + # Add publishing fields to sensors table + op.add_column('sensors', sa.Column('is_published', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('sensors', sa.Column('published_at', sa.TIMESTAMP(timezone=True), nullable=True)) + + # Add publishing fields to measurements table + op.add_column('measurements', sa.Column('is_published', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('measurements', sa.Column('published_at', sa.TIMESTAMP(timezone=True), nullable=True)) + + # Create indexes for better query performance + op.create_index('idx_campaigns_is_published', 'campaigns', ['is_published']) + op.create_index('idx_stations_is_published', 'stations', ['is_published']) + op.create_index('idx_sensors_is_published', 'sensors', ['is_published']) + op.create_index('idx_measurements_is_published', 'measurements', ['is_published']) + + +def downgrade() -> None: + """Remove publishing status fields from all tables.""" + # Drop indexes + op.drop_index('idx_measurements_is_published', table_name='measurements') + op.drop_index('idx_sensors_is_published', table_name='sensors') + op.drop_index('idx_stations_is_published', table_name='stations') + op.drop_index('idx_campaigns_is_published', table_name='campaigns') + + # Remove columns from measurements table + op.drop_column('measurements', 'published_at') + op.drop_column('measurements', 'is_published') + + # Remove columns from sensors table + op.drop_column('sensors', 'published_at') + op.drop_column('sensors', 'is_published') + + # Remove columns from stations table + op.drop_column('stations', 'published_at') + op.drop_column('stations', 'is_published') + + # Remove columns from campaigns table + op.drop_column('campaigns', 'published_at') + op.drop_column('campaigns', 'is_published') diff --git a/app/api/dependencies/auth.py b/app/api/dependencies/auth.py index 34f3316..c7c9c0f 100644 --- a/app/api/dependencies/auth.py +++ b/app/api/dependencies/auth.py @@ -1,6 +1,7 @@ import jwt from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer +from typing import Optional from app.api.v1.schemas.user import User from app.pytas.http import TASClient # type: ignore[attr-defined] @@ -52,3 +53,22 @@ def unhash(token: str) -> dict[str, str]: def hash(payload: dict[str, str]) -> str: return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.ALG) + + +# Optional authentication - allows both authenticated and unauthenticated access +oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="api/v1/token", auto_error=False) + +async def get_current_user_optional(token: str | None = Depends(oauth2_scheme_optional)) -> User | None: + """Get current user if authenticated, otherwise return None for public access.""" + if not token: + return None + + if settings.ENV == "dev": + return User(username="test") + + try: + user_dict = unhash(token) + return User(username=user_dict["username"]) + except jwt.InvalidTokenError: + # Invalid token = treat as unauthenticated (public access) + return None diff --git a/app/api/v1/main.py b/app/api/v1/main.py index 634e842..80be336 100644 --- a/app/api/v1/main.py +++ b/app/api/v1/main.py @@ -13,6 +13,7 @@ router as sensor_variables_router, ) from app.api.v1.routes.campaigns.root import router as campaigns_router +from app.api.v1.routes.campaigns.permissions import router as permissions_router from app.api.v1.routes.root import router as root_router from app.api.v1.routes.upload_file.upload_csv import router as upload_file_csv_router # type: ignore[attr-defined] from app.api.v1.routes.projects.projects import router as projects_router @@ -20,6 +21,7 @@ api_router = APIRouter() api_router.include_router(root_router) api_router.include_router(campaigns_router) +api_router.include_router(permissions_router) api_router.include_router(stations_router) api_router.include_router(campaign_station_sensors_router) api_router.include_router(campaign_station_sensor_measurements_router) 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..7e8c4ac 100644 --- a/app/api/v1/routes/campaigns/campaign_station_sensor_measurements.py +++ b/app/api/v1/routes/campaigns/campaign_station_sensor_measurements.py @@ -3,15 +3,17 @@ from sqlalchemy.orm import Session from fastapi import APIRouter, Depends, HTTPException, Query, Response -from app.api.dependencies.auth import get_current_user +from app.api.dependencies.auth import get_current_user, get_current_user_optional from app.api.dependencies.pytas import check_allocation_permission from app.api.v1.schemas.user import User from app.api.v1.schemas.measurement import AggregatedMeasurement, ListMeasurementsResponsePagination, MeasurementCreateResponse, MeasurementUpdate, MeasurementIn +from app.api.v1.schemas.campaign import PublishRequest, PublishResponse from app.db.repositories.measurement_repository import MeasurementRepository from app.db.repositories.sensor_repository import SensorRepository from app.db.session import get_db from app.services.measurement_service import MeasurementService from app.services.sensor_service import SensorService +from app.services.publishing_service import PublishingService router = APIRouter( prefix="/campaigns/{campaign_id}/stations/{station_id}/sensors/{sensor_id}", @@ -42,17 +44,23 @@ async def get_sensor_measurements( end_date: datetime | None = None, min_measurement_value: float | None = None, max_measurement_value: float | None = None, - current_user: User = Depends(get_current_user), + current_user: User | None = Depends(get_current_user_optional), limit: int = 1000, page: int = 1, downsample_threshold: int | None = None, db: Session = Depends(get_db), ) -> ListMeasurementsResponsePagination: - if not check_allocation_permission(current_user, campaign_id): - raise HTTPException(status_code=404, detail="Allocation is incorrect") measurement_repository = MeasurementRepository(db) 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) + + if current_user: + # Authenticated user - check allocation permissions, show all measurements + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + 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, published_only=False) + else: + # Unauthenticated user - show only published measurements + 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, published_only=True) @router.get("/measurements/confidence-intervals", response_model=list[AggregatedMeasurement]) async def get_measurements_with_confidence_intervals( @@ -127,4 +135,44 @@ def partial_update_sensor( updated_measurement = measurement_service.partial_update_measurement(measurement_id, measurement) if not updated_measurement: raise HTTPException(status_code=404, detail="Measurement not found") - return updated_measurement \ No newline at end of file + return updated_measurement + + +@router.post("/measurements/{measurement_id}/publish", response_model=PublishResponse) +def publish_measurement( + campaign_id: int, + station_id: int, + sensor_id: int, + measurement_id: int, + publish_request: PublishRequest = PublishRequest(), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> PublishResponse: + """Publish a measurement.""" + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + + publishing_service = PublishingService(db) + result = publishing_service.publish_measurement( + measurement_id, + force=publish_request.force + ) + return PublishResponse(**result) + + +@router.post("/measurements/{measurement_id}/unpublish", response_model=PublishResponse) +def unpublish_measurement( + campaign_id: int, + station_id: int, + sensor_id: int, + measurement_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> PublishResponse: + """Unpublish a measurement.""" + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + + publishing_service = PublishingService(db) + result = publishing_service.unpublish_measurement(measurement_id) + return PublishResponse(**result) \ No newline at end of file diff --git a/app/api/v1/routes/campaigns/campaign_station_sensors.py b/app/api/v1/routes/campaigns/campaign_station_sensors.py index 4cd8876..35d47d2 100644 --- a/app/api/v1/routes/campaigns/campaign_station_sensors.py +++ b/app/api/v1/routes/campaigns/campaign_station_sensors.py @@ -2,15 +2,17 @@ from sqlalchemy.orm import Session from fastapi import APIRouter, Depends, HTTPException, Query, Response -from app.api.dependencies.auth import get_current_user +from app.api.dependencies.auth import get_current_user, get_current_user_optional from app.api.dependencies.pytas import check_allocation_permission from app.api.v1.schemas.sensor import SensorItem, GetSensorResponse, ListSensorsResponsePagination, SensorStatistics, SensorCreateResponse, SensorUpdate, ForceUpdateSensorStatisticsResponse, UpdateSensorStatisticsResponse +from app.api.v1.schemas.campaign import PublishRequest, PublishResponse from app.api.v1.schemas.user import User from app.db.session import get_db from app.db.repositories.sensor_repository import SensorRepository, SortField from app.db.repositories.station_repository import StationRepository from app.services.sensor_service import SensorService from app.services.station_service import StationService +from app.services.publishing_service import PublishingService from app.db.repositories.measurement_repository import MeasurementRepository @@ -29,32 +31,49 @@ async def list_sensors( 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)"), - postprocess: Optional[bool] = Query(None, description="Filter sensors by postprocess flag"), - current_user: User = Depends(get_current_user), + postprocess: bool | None = Query(None, description="Filter sensors by postprocess flag"), + current_user: User | None = Depends(get_current_user_optional), db: Session = Depends(get_db), - sort_by: Optional[SortField] = Query(None, description="Sort sensors by field"), + sort_by: SortField | None = Query(None, description="Sort sensors by field"), sort_order: str = Query("asc", description="Sort order (asc or desc)"), ) -> ListSensorsResponsePagination: - 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) ) - items, total_count = sensor_service.get_sensors_by_station_id( - station_id=station_id, - page=page, - limit=limit, - variable_name=variable_name, - units=units, - alias=alias, - description_contains=description_contains, - postprocess=postprocess, - sort_by=sort_by, - sort_order=sort_order - ) + if current_user: + # Authenticated user - check allocation permissions, show all sensors + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + items, total_count = sensor_service.get_sensors_by_station_id( + station_id=station_id, + page=page, + limit=limit, + variable_name=variable_name, + units=units, + alias=alias, + description_contains=description_contains, + postprocess=postprocess, + sort_by=sort_by, + sort_order=sort_order, + published_only=False + ) + else: + # Unauthenticated user - show only published sensors in published stations/campaigns + items, total_count = sensor_service.get_sensors_by_station_id( + station_id=station_id, + page=page, + limit=limit, + variable_name=variable_name, + units=units, + alias=alias, + description_contains=description_contains, + postprocess=postprocess, + sort_by=sort_by, + sort_order=sort_order, + published_only=True + ) return ListSensorsResponsePagination( items=items, @@ -69,18 +88,23 @@ async def get_sensor( station_id: int, sensor_id: int, campaign_id: int, - current_user: User = Depends(get_current_user), + current_user: User | None = Depends(get_current_user_optional), db: Session = Depends(get_db) ) -> GetSensorResponse: - 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) ) - response = sensor_service.get_sensor(sensor_id) + if current_user: + # Authenticated user - check allocation permissions, show all sensors + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Sensor not found") + response = sensor_service.get_sensor(sensor_id, published_only=False) + else: + # Unauthenticated user - show only if published and parent entities are published + response = sensor_service.get_sensor(sensor_id, published_only=True) + if response is None: raise HTTPException(status_code=404, detail="Sensor not found") return response @@ -201,4 +225,43 @@ def delete_sensor_sensor_id( if not success: raise HTTPException(status_code=404, detail="Sensor not found") - return Response(status_code=204) \ No newline at end of file + return Response(status_code=204) + + +@router.post("/sensors/{sensor_id}/publish", response_model=PublishResponse) +def publish_sensor( + campaign_id: int, + station_id: int, + sensor_id: int, + publish_request: PublishRequest = PublishRequest(), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> PublishResponse: + """Publish a sensor with optional cascading to measurements.""" + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + + publishing_service = PublishingService(db) + result = publishing_service.publish_sensor( + sensor_id, + cascade=publish_request.cascade, + force=publish_request.force + ) + return PublishResponse(**result) + + +@router.post("/sensors/{sensor_id}/unpublish", response_model=PublishResponse) +def unpublish_sensor( + campaign_id: int, + station_id: int, + sensor_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> PublishResponse: + """Unpublish a sensor.""" + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + + publishing_service = PublishingService(db) + result = publishing_service.unpublish_sensor(sensor_id) + return PublishResponse(**result) \ No newline at end of file diff --git a/app/api/v1/routes/campaigns/campaign_stations.py b/app/api/v1/routes/campaigns/campaign_stations.py index 6c23b1d..31dd273 100644 --- a/app/api/v1/routes/campaigns/campaign_stations.py +++ b/app/api/v1/routes/campaigns/campaign_stations.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session -from app.api.dependencies.auth import get_current_user +from app.api.dependencies.auth import get_current_user, get_current_user_optional from app.api.dependencies.pytas import check_allocation_permission from app.api.v1.schemas.station import ( GetStationResponse, @@ -14,6 +14,7 @@ StationCreateResponse, StationUpdate, ) +from app.api.v1.schemas.campaign import PublishRequest, PublishResponse from app.api.v1.schemas.user import User from app.db.session import get_db from app.db.repositories.station_repository import StationRepository @@ -22,6 +23,7 @@ from app.db.repositories.measurement_repository import MeasurementRepository from app.services.station_service import StationService from app.services.export_service import ExportService +from app.services.publishing_service import PublishingService router = APIRouter(prefix="/campaigns/{campaign_id}", tags=["stations"]) @@ -45,15 +47,24 @@ async def list_stations( campaign_id: int, page: int = 1, limit: int = 20, - current_user: User = Depends(get_current_user), + current_user: User | None = Depends(get_current_user_optional), db: Session = Depends(get_db), ) -> ListStationsResponsePagination: - if not check_allocation_permission(current_user, campaign_id): - raise HTTPException(status_code=404, detail="Allocation is incorrect") station_service = StationService(StationRepository(db)) - stations, total_count = station_service.get_stations_with_summary( - campaign_id, page, limit - ) + + if current_user: + # Authenticated user - check allocation permissions, show all stations + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + stations, total_count = station_service.get_stations_with_summary( + campaign_id, page, limit, published_only=False + ) + else: + # Unauthenticated user - show only published stations in published campaigns + stations, total_count = station_service.get_stations_with_summary( + campaign_id, page, limit, published_only=True + ) + return ListStationsResponsePagination( items=stations, total=total_count, @@ -68,13 +79,20 @@ async def list_stations( async def get_station( station_id: int, campaign_id: int, - current_user: User = Depends(get_current_user), + current_user: User | None = Depends(get_current_user_optional), db: Session = Depends(get_db), ) -> GetStationResponse: - if not check_allocation_permission(current_user, campaign_id): - raise HTTPException(status_code=404, detail="Allocation is incorrect") station_service = StationService(StationRepository(db)) - station = station_service.get_station(station_id) + + if current_user: + # Authenticated user - check allocation permissions, show all stations + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Station not found") + station = station_service.get_station(station_id, published_only=False) + else: + # Unauthenticated user - show only if published and campaign is published + station = station_service.get_station(station_id, published_only=True) + if not station: raise HTTPException(status_code=404, detail="Station not found") return station @@ -210,3 +228,40 @@ async def export_measurements_csv( "Content-Disposition": f'attachment; filename="measurements-{station_id}.csv"' }, ) + + +@router.post("/stations/{station_id}/publish", response_model=PublishResponse) +def publish_station( + campaign_id: int, + station_id: int, + publish_request: PublishRequest = PublishRequest(), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> PublishResponse: + """Publish a station with optional cascading to sensors and measurements.""" + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + + publishing_service = PublishingService(db) + result = publishing_service.publish_station( + station_id, + cascade=publish_request.cascade, + force=publish_request.force + ) + return PublishResponse(**result) + + +@router.post("/stations/{station_id}/unpublish", response_model=PublishResponse) +def unpublish_station( + campaign_id: int, + station_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> PublishResponse: + """Unpublish a station.""" + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + + publishing_service = PublishingService(db) + result = publishing_service.unpublish_station(station_id) + return PublishResponse(**result) diff --git a/app/api/v1/routes/campaigns/permissions.py b/app/api/v1/routes/campaigns/permissions.py new file mode 100644 index 0000000..7dbf186 --- /dev/null +++ b/app/api/v1/routes/campaigns/permissions.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.dependencies.auth import get_current_user +from app.db.session import get_db +from app.api.dependencies.pytas import check_allocation_permission +from app.api.v1.schemas.user import User + +router = APIRouter() + + +@router.get("/campaigns/{campaign_id}/permissions") +def get_campaign_permissions( + campaign_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> dict: + """ + Get user permissions for a specific campaign. + Returns what actions the current user can perform on the campaign. + """ + # Check if user has access to this campaign + has_access = check_allocation_permission(current_user, campaign_id) + + # In the current system, if you have access, you can delete + # This can be extended in the future for more granular permissions + can_delete = has_access + can_edit = has_access + can_view = has_access + + return { + "campaign_id": campaign_id, + "permissions": { + "can_view": can_view, + "can_edit": can_edit, + "can_delete": can_delete, + "can_create_stations": has_access, + "can_delete_stations": can_delete, + "can_create_sensors": has_access, + "can_delete_sensors": can_delete, + } + } \ No newline at end of file diff --git a/app/api/v1/routes/campaigns/root.py b/app/api/v1/routes/campaigns/root.py index 41caaac..855ebbe 100644 --- a/app/api/v1/routes/campaigns/root.py +++ b/app/api/v1/routes/campaigns/root.py @@ -1,11 +1,11 @@ from datetime import datetime -from typing import Annotated +from typing import Annotated, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy.orm import Session from app.api.dependencies.pytas import check_allocation_permission -from app.api.dependencies.auth import get_current_user +from app.api.dependencies.auth import get_current_user, get_current_user_optional from app.api.dependencies.pytas import get_allocations from app.api.v1.schemas.campaign import ( CampaignCreateResponse, @@ -13,11 +13,14 @@ ListCampaignsResponsePagination, CampaignsIn, CampaignUpdate, + PublishRequest, + PublishResponse, ) from app.api.v1.schemas.user import User from app.db.repositories.campaign_repository import CampaignRepository from app.db.session import get_db from app.services.campaign_service import CampaignService +from app.services.publishing_service import PublishingService router = APIRouter(prefix="/campaigns", tags=["campaigns"]) @@ -52,14 +55,23 @@ async def list_campaigns( sensor_variables: Annotated[ list[str] | None, Query(description="List of sensor variables to filter by") ] = None, - current_user: User = Depends(get_current_user), + current_user: User | None = Depends(get_current_user_optional), db: Session = Depends(get_db), ) -> ListCampaignsResponsePagination: - allocations = get_allocations(current_user.username) campaign_service = CampaignService(CampaignRepository(db)) - results, total_count = campaign_service.get_campaigns_with_summary( - allocations, bbox, start_date, end_date, sensor_variables, page, limit - ) + + if current_user: + # Authenticated user - show all campaigns they have access to + allocations = get_allocations(current_user.username) + results, total_count = campaign_service.get_campaigns_with_summary( + allocations, bbox, start_date, end_date, sensor_variables, page, limit, published_only=False + ) + else: + # Unauthenticated user - show only published campaigns + results, total_count = campaign_service.get_campaigns_with_summary( + None, bbox, start_date, end_date, sensor_variables, page, limit, published_only=True + ) + response = ListCampaignsResponsePagination( items=results, total=total_count, @@ -73,11 +85,20 @@ async def list_campaigns( @router.get("/{campaign_id}") async def get_campaign( campaign_id: int, - current_user: User = Depends(get_current_user), + current_user: User | None = Depends(get_current_user_optional), db: Session = Depends(get_db), ) -> GetCampaignResponse: campaign_service = CampaignService(CampaignRepository(db)) - campaign = campaign_service.get_campaign_with_summary(campaign_id) + + if current_user: + # Authenticated user - check allocation permissions + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Campaign not found") + campaign = campaign_service.get_campaign_with_summary(campaign_id, published_only=False) + else: + # Unauthenticated user - only show if published + campaign = campaign_service.get_campaign_with_summary(campaign_id, published_only=True) + if not campaign: raise HTTPException(status_code=404, detail="Campaign not found") return campaign @@ -127,3 +148,38 @@ def partial_update_campaign( if not updated_campaign: raise HTTPException(status_code=404, detail="Campaign not found") return updated_campaign + + +@router.post("/{campaign_id}/publish", response_model=PublishResponse) +def publish_campaign( + campaign_id: int, + publish_request: PublishRequest = PublishRequest(), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> PublishResponse: + """Publish a campaign with optional cascading to stations, sensors, and measurements.""" + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + + publishing_service = PublishingService(db) + result = publishing_service.publish_campaign( + campaign_id, + cascade=publish_request.cascade, + force=publish_request.force + ) + return PublishResponse(**result) + + +@router.post("/{campaign_id}/unpublish", response_model=PublishResponse) +def unpublish_campaign( + campaign_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> PublishResponse: + """Unpublish a campaign.""" + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + + publishing_service = PublishingService(db) + result = publishing_service.unpublish_campaign(campaign_id) + return PublishResponse(**result) diff --git a/app/api/v1/schemas/campaign.py b/app/api/v1/schemas/campaign.py index 1c80d3c..198979f 100644 --- a/app/api/v1/schemas/campaign.py +++ b/app/api/v1/schemas/campaign.py @@ -38,6 +38,8 @@ class ListCampaignsResponseItem(BaseModel): start_date: datetime | None = None end_date: datetime | None = None allocation: str | None = None + is_published: bool = False + published_at: datetime | None = None summary: SummaryListCampaigns geometry: dict = Field(default_factory=dict, nullable=True) # type: ignore[call-overload,type-arg] @@ -63,6 +65,8 @@ class GetCampaignResponse(BaseModel): start_date: datetime | None = None end_date: datetime | None = None allocation: str + is_published: bool = False + published_at: datetime | None = None location: Location | None = None summary: SummaryGetCampaign geometry: dict = Field(default_factory=dict, nullable=True) # type: ignore[call-overload,type-arg] @@ -70,10 +74,23 @@ class GetCampaignResponse(BaseModel): class CampaignUpdate(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - contact_name: Optional[str] = None - contact_email: Optional[str] = None - allocation: Optional[str] = None - start_date: Optional[datetime] = None - end_date: Optional[datetime] = None \ No newline at end of file + name: str | None = None + description: str | None = None + contact_name: str | None = None + contact_email: str | None = None + allocation: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + + +class PublishRequest(BaseModel): + cascade: bool = False + force: bool = False + + +class PublishResponse(BaseModel): + id: int + type: str + is_published: bool + published_at: datetime | None = None + cascaded_items: List[str] = [] \ No newline at end of file diff --git a/app/api/v1/schemas/measurement.py b/app/api/v1/schemas/measurement.py index 820d1f2..440b6cd 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 List, Optional from geoalchemy2 import Geometry from geojson_pydantic import Point @@ -8,17 +8,17 @@ # Pydantic model for incoming measurement data class MeasurementIn(BaseModel): - sensorid: Optional[int] = None + sensorid: int | None = None collectiontime: datetime - geometry: Optional[str] = Field( + geometry: str | None = Field( default=None, description='Geometry in Well-Known Text (WKT) format, e.g. "POINT(longitude latitude)"', examples=['POINT(10.12345 20.54321)'] ) measurementvalue: float - variablename: Optional[str] = None # modified - variabletype: Optional[str] = None - description: Optional[str] = None + variablename: str | None = None # modified + variabletype: str | None = None + description: str | None = None class MeasurementCreateResponse(BaseModel): id: int @@ -67,14 +67,14 @@ class Config: # Pydantic model for incoming measurement data class MeasurementUpdate(BaseModel): - sensorid: Optional[int] = None - collectiontime: Optional[datetime] = None - geometry: Optional[str] = Field( + sensorid: int | None = None + collectiontime: datetime | None = None + geometry: str | None = Field( default=None, description='Geometry in Well-Known Text (WKT) format, e.g. "POINT(longitude latitude)"', examples=['POINT(10.12345 20.54321)'] ) - measurementvalue: Optional[float] = None - variablename: Optional[str] = None # modified - variabletype: Optional[str] = None - description: Optional[str] = None \ No newline at end of file + measurementvalue: float | None = None + variablename: str | None = None # modified + variabletype: str | None = None + description: str | None = None \ No newline at end of file diff --git a/app/api/v1/schemas/sensor.py b/app/api/v1/schemas/sensor.py index 51ac517..f8ecbc7 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 from pydantic import BaseModel @@ -9,10 +9,10 @@ # Pydantic model for incoming sensor data class SensorIn(BaseModel): alias: str | float - description: Optional[str] = None - postprocess: Optional[bool] = True - postprocessscript: Optional[str] = None - units: Optional[str] = None + description: str | None = None + postprocess: bool | None = True + postprocessscript: str | None = None + units: str | None = None variablename: str | None = None class SensorCreateResponse(BaseModel): @@ -43,6 +43,8 @@ class SensorItem(BaseModel): units: str | None = None variablename: str | None = None statistics: SensorStatistics | None = None + is_published: bool = False + published_at: datetime | None = None class ListSensorsResponse(SensorItem): pass @@ -66,12 +68,12 @@ class ListSensorsResponsePagination(BaseModel): class SensorUpdate(BaseModel): - alias: Optional[str] = None - description: Optional[str] = None - postprocess: Optional[bool] = True - postprocessscript: Optional[str] = None - units: Optional[str] = None - variablename: Optional[str] | None = None + alias: str | None = None + description: str | None = None + postprocess: bool | None = True + postprocessscript: str | None = None + units: str | None = None + variablename: str | None = None class ForceUpdateSensorStatisticsResponse(BaseModel): diff --git a/app/api/v1/schemas/station.py b/app/api/v1/schemas/station.py index 95e2070..27e800b 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, List from pydantic import BaseModel, Field from app.api.v1.schemas.sensor import SensorItem @@ -29,6 +29,8 @@ class StationItem(BaseModel): contact_email: str | None = None active: bool | None = None start_date: datetime | None = None + is_published: bool = False + published_at: datetime | None = None geometry: dict = Field(default_factory=dict, nullable=True) # type: ignore[call-overload,type-arg] class StationItemWithSummary(StationItem): @@ -58,10 +60,10 @@ 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 + name: str | None = None + description: str | None = None + contact_name: str | None = None + contact_email: str | None = None + active: bool | None = None + start_date: datetime | None = None + station_type: StationType | None = 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..883ce6f 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, diff --git a/app/db/models/campaign.py b/app/db/models/campaign.py index c3bd4c1..108a873 100644 --- a/app/db/models/campaign.py +++ b/app/db/models/campaign.py @@ -11,17 +11,20 @@ class Campaign(Base): campaignid: Mapped[int] = mapped_column(primary_key=True, index=True) campaignname: Mapped[str] = mapped_column(unique=True) - description: Mapped[Optional[str]] = mapped_column() - contactname: Mapped[Optional[str]] = mapped_column() - contactemail: Mapped[Optional[str]] = mapped_column() - startdate: Mapped[Optional[datetime]] = mapped_column() - enddate: Mapped[Optional[datetime]] = mapped_column() + description: Mapped[str | None] = mapped_column() + contactname: Mapped[str | None] = mapped_column() + contactemail: Mapped[str | None] = mapped_column() + startdate: Mapped[datetime | None] = mapped_column() + enddate: Mapped[datetime | None] = mapped_column() allocation: Mapped[str] = mapped_column() - bbox_west: Mapped[Optional[float]] = mapped_column() - bbox_east: Mapped[Optional[float]] = mapped_column() - bbox_south: Mapped[Optional[float]] = mapped_column() - bbox_north: Mapped[Optional[float]] = mapped_column() + bbox_west: Mapped[float | None] = mapped_column() + bbox_east: Mapped[float | None] = mapped_column() + bbox_south: Mapped[float | None] = mapped_column() + bbox_north: Mapped[float | None] = mapped_column() geometry: Mapped[geoalchemy2.types.Geometry] = mapped_column(geoalchemy2.types.Geometry("GEOMETRY", srid=4326)) + # publishing fields + is_published: Mapped[bool] = mapped_column(default=False) + published_at: Mapped[datetime | None] = mapped_column() # relationships stations: Mapped[List["Station"]] = relationship( back_populates="campaign" diff --git a/app/db/models/measurement.py b/app/db/models/measurement.py index 786fb39..fe2959c 100644 --- a/app/db/models/measurement.py +++ b/app/db/models/measurement.py @@ -17,9 +17,14 @@ class Measurement(Base): collectiontime: Mapped[datetime] = mapped_column() measurementvalue: Mapped[float] = mapped_column() geometry: Mapped[Geometry] = mapped_column(Geometry("POINT", srid=4326)) - variablename: Mapped[Optional[str]] = mapped_column() - variabletype: Mapped[Optional[str]] = mapped_column() - description: Mapped[Optional[str]] = mapped_column() + variablename: Mapped[str | None] = mapped_column() + variabletype: Mapped[str | None] = mapped_column() + description: Mapped[str | None] = mapped_column() + + # publishing fields + is_published: Mapped[bool] = mapped_column(default=False) + published_at: Mapped[datetime | None] = mapped_column() + # relationships sensor: Mapped["Sensor"] = relationship( back_populates="measurements", lazy="joined" diff --git a/app/db/models/sensor.py b/app/db/models/sensor.py index f8eb196..0a2a0d1 100644 --- a/app/db/models/sensor.py +++ b/app/db/models/sensor.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import List, Optional from sqlalchemy import ForeignKey @@ -11,16 +12,19 @@ class Sensor(Base): sensorid: Mapped[int] = mapped_column(primary_key=True, index=True) stationid: Mapped[int] = mapped_column(ForeignKey("stations.stationid")) - alias: Mapped[Optional[str]] = mapped_column() - description: Mapped[Optional[str]] = mapped_column() - postprocess: Mapped[Optional[bool]] = mapped_column() - postprocessscript: Mapped[Optional[str]] = mapped_column() - units: Mapped[Optional[str]] = mapped_column() - variablename: Mapped[Optional[str]] = mapped_column() + alias: Mapped[str | None] = mapped_column() + description: Mapped[str | None] = mapped_column() + postprocess: Mapped[bool | None] = mapped_column() + postprocessscript: Mapped[str | None] = mapped_column() + units: Mapped[str | None] = mapped_column() + variablename: Mapped[str | None] = mapped_column() upload_file_events_id: Mapped[int] = mapped_column( ForeignKey("upload_file_events.id", ondelete="CASCADE") ) + # publishing fields + is_published: Mapped[bool] = mapped_column(default=False) + published_at: Mapped[datetime | None] = mapped_column() # relationships station: Mapped["Station"] = relationship("Station", back_populates="sensors") diff --git a/app/db/models/sensor_statistics.py b/app/db/models/sensor_statistics.py index 8bb677d..4d66e91 100644 --- a/app/db/models/sensor_statistics.py +++ b/app/db/models/sensor_statistics.py @@ -15,21 +15,21 @@ class SensorStatistics(Base): ForeignKey('sensors.sensorid', ondelete='CASCADE'), primary_key=True ) - max_value: Mapped[Optional[float]] = mapped_column(Numeric, nullable=True) - min_value: Mapped[Optional[float]] = mapped_column(Numeric, nullable=True) - avg_value: Mapped[Optional[float]] = mapped_column(Numeric, nullable=True) - stddev_value: Mapped[Optional[float]] = mapped_column(Numeric, nullable=True) - percentile_90: Mapped[Optional[float]] = mapped_column(Numeric, nullable=True) - percentile_95: Mapped[Optional[float]] = mapped_column(Numeric, nullable=True) - percentile_99: Mapped[Optional[float]] = mapped_column(Numeric, nullable=True) - count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) - first_measurement_value: Mapped[Optional[float]] = mapped_column(Numeric, nullable=True) - first_measurement_collectiontime: Mapped[Optional[datetime]] = mapped_column( + max_value: Mapped[float | None] = mapped_column(Numeric, nullable=True) + min_value: Mapped[float | None] = mapped_column(Numeric, nullable=True) + avg_value: Mapped[float | None] = mapped_column(Numeric, nullable=True) + stddev_value: Mapped[float | None] = mapped_column(Numeric, nullable=True) + percentile_90: Mapped[float | None] = mapped_column(Numeric, nullable=True) + percentile_95: Mapped[float | None] = mapped_column(Numeric, nullable=True) + percentile_99: Mapped[float | None] = mapped_column(Numeric, nullable=True) + count: Mapped[int | None] = mapped_column(Integer, nullable=True) + first_measurement_value: Mapped[float | None] = mapped_column(Numeric, nullable=True) + first_measurement_collectiontime: Mapped[datetime | None] = mapped_column( TIMESTAMP(timezone=True), nullable=True ) - last_measurement_value: Mapped[Optional[float]] = mapped_column(Numeric, nullable=True) - last_measurement_collectiontime: Mapped[Optional[datetime]] = mapped_column( + last_measurement_value: Mapped[float | None] = mapped_column(Numeric, nullable=True) + last_measurement_collectiontime: Mapped[datetime | None] = mapped_column( TIMESTAMP(timezone=True), nullable=True ) diff --git a/app/db/models/station.py b/app/db/models/station.py index 4fc77f3..0ad086a 100644 --- a/app/db/models/station.py +++ b/app/db/models/station.py @@ -15,12 +15,12 @@ class Station(Base): ForeignKey("campaigns.campaignid"), nullable=True ) stationname: Mapped[str] = mapped_column(unique=True) - projectid: Mapped[Optional[str]] = mapped_column() - description: Mapped[Optional[str]] = mapped_column() - contactname: Mapped[Optional[str]] = mapped_column() - contactemail: Mapped[Optional[str]] = mapped_column() - active: Mapped[Optional[bool]] = mapped_column() - startdate: Mapped[Optional[datetime]] = mapped_column() + projectid: Mapped[str | None] = mapped_column() + description: Mapped[str | None] = mapped_column() + contactname: Mapped[str | None] = mapped_column() + contactemail: Mapped[str | None] = mapped_column() + active: Mapped[bool | None] = mapped_column() + startdate: Mapped[datetime | None] = mapped_column() # Station type @@ -29,6 +29,10 @@ class Station(Base): # Location for static stations geometry: Mapped[geoalchemy2.types.Geometry] = mapped_column(geoalchemy2.types.Geometry("GEOMETRY", srid=4326)) + # publishing fields + is_published: Mapped[bool] = mapped_column(default=False) + published_at: Mapped[datetime | None] = mapped_column() + # relationships campaign: Mapped["Campaign"] = relationship( back_populates="stations" diff --git a/app/db/repositories/campaign_repository.py b/app/db/repositories/campaign_repository.py index 4bcc8de..f01842f 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, Optional 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, published_only: bool = False) -> Campaign | None: stmt = ( select(Campaign) .options( @@ -39,6 +39,9 @@ def get_campaign(self, id: int) -> Campaign | None: ) .filter(Campaign.campaignid == id) ) + + if published_only: + stmt = stmt.filter(Campaign.is_published == True) result = self.db.execute(stmt).first() if not result: @@ -65,6 +68,7 @@ def get_campaigns_and_summary( sensor_variables: list[str] | None, page: int = 1, limit: int = 20, + published_only: bool = False, ) -> tuple[list[tuple[Campaign, int, int, list[str | None], list[str | None], str | None]], int]: # Base campaign query query = self.db.query( @@ -110,6 +114,9 @@ def get_campaigns_and_summary( if sensor_variables: query = query.filter(Sensor.variablename.in_(sensor_variables)) + if published_only: + query = query.filter(Campaign.is_published == True) + total_count = query.count() # Get paginated results diff --git a/app/db/repositories/measurement_repository.py b/app/db/repositories/measurement_repository.py index 0e3010c..79c33c7 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 import typing from sqlalchemy.orm import Session diff --git a/app/db/repositories/sensor_repository.py b/app/db/repositories/sensor_repository.py index 0b57a0e..d5a30db 100644 --- a/app/db/repositories/sensor_repository.py +++ b/app/db/repositories/sensor_repository.py @@ -64,13 +64,16 @@ def create_sensors(self, sensors: list[Sensor]) -> list[Sensor]: self.db.commit() return sensors - def get_sensor(self, sensor_id: int) -> GetSensorResponse | None: + def get_sensor(self, sensor_id: int, published_only: bool = False) -> GetSensorResponse | None: stmt = ( select(Sensor, SensorStatistics) .outerjoin(SensorStatistics, Sensor.sensorid == SensorStatistics.sensorid) .where(Sensor.sensorid == sensor_id) ) + if published_only: + stmt = stmt.where(Sensor.is_published == True) + result = self.db.execute(stmt).first() if result is None: @@ -111,6 +114,9 @@ def get_sensor(self, sensor_id: int) -> GetSensorResponse | None: statistics.stats_last_updated if statistics else None ), ), + # publishing fields + is_published=getattr(sensor, 'is_published', False), + published_at=getattr(sensor, 'published_at', None), ) def delete_sensor_statistics(self, sensor_id: int) -> bool: @@ -169,8 +175,9 @@ def get_sensors_by_station_id( alias: str | None = None, description_contains: str | None = None, postprocess: bool | None = None, - sort_by: Optional[SortField] = None, + sort_by: SortField | None = None, sort_order: str = "asc", + published_only: bool = False, ) -> Tuple[list[Row[Tuple[Sensor, SensorStatistics]]], int]: count_stmt = ( select(func.count()) @@ -202,6 +209,10 @@ def get_sensors_by_station_id( stmt = stmt.where(Sensor.postprocess == postprocess) count_stmt = count_stmt.where(Sensor.postprocess == postprocess) + if published_only: + stmt = stmt.where(Sensor.is_published == True) + count_stmt = count_stmt.where(Sensor.is_published == True) + # Handle sorting if sort_by: sort_column = self.get_sort_column(sort_by) @@ -218,12 +229,12 @@ def get_sensors_by_station_id( def get_sensors( self, - station_id: Optional[int] = None, - variable_name: Optional[str] = None, - postprocess: Optional[bool] = None, + station_id: int | None = None, + variable_name: str | None = None, + postprocess: bool | None = None, page: int = 1, limit: int = 20, - sort_by: Optional[SortField] = None, + sort_by: SortField | None = None, sort_order: str = "asc", ) -> tuple[list[Row[Tuple[Sensor, SensorStatistics]]], int]: stmt = select(Sensor, SensorStatistics).outerjoin( diff --git a/app/db/repositories/station_repository.py b/app/db/repositories/station_repository.py index fa81090..9022d96 100644 --- a/app/db/repositories/station_repository.py +++ b/app/db/repositories/station_repository.py @@ -5,6 +5,7 @@ from sqlalchemy import func from sqlalchemy.orm import joinedload +import logging from app.api.v1.schemas.station import StationCreate, StationUpdate from app.db.models.sensor import Sensor from app.db.models.station import Station @@ -30,7 +31,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, published_only: bool = False) -> Station | None: # Query the station with its sensors and convert geometry to GeoJSON result = self.db.query( Station, @@ -39,10 +40,21 @@ def get_station(self, station_id: int) -> Station | None: joinedload(Station.sensors) ).filter( Station.stationid == station_id - ).first() + ) + + if published_only: + result = result.filter(Station.is_published == True) + + result = result.first() if result: station, geometry_str = result + # Debug: log publishing state retrieved from DB + try: + import logging + logging.info("StationRepository.get_station: fetched station %s is_published=%s published_at=%s", station.stationid, getattr(station, 'is_published', None), getattr(station, 'published_at', None)) + except Exception: + pass # Create a new Station instance with the GeoJSON geometry station_dict = { 'stationid': station.stationid, @@ -56,30 +68,44 @@ def get_station(self, station_id: int) -> Station | None: 'startdate': station.startdate, 'station_type': station.station_type, 'geometry': geometry_str, - 'sensors': station.sensors + 'sensors': station.sensors, + # publishing fields - include so callers can see current DB state + 'is_published': getattr(station, 'is_published', False), + 'published_at': getattr(station, 'published_at', None), } + # Log the dictionary we are about to return so callers can inspect values + try: + logging.info("StationRepository.get_station: station_dict=%s", station_dict) + except Exception: + # best-effort logging; ignore failures + pass return Station(**station_dict) return None 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, published_only: bool = False) -> tuple[list[tuple[Station, int, list[str | None], list[str | None], str | None]], int]: query = self.db.query(Station, func.count(Sensor.sensorid.distinct()).label('sensor_count'), func.array_agg(func.distinct(Sensor.alias)).label('sensor_types'), func.array_agg(func.distinct(Sensor.variablename)).label('sensor_variables'), func.ST_AsGeoJSON(Station.geometry).label('geometry') - ).select_from(Station).outerjoin(Sensor).filter(Station.campaignid == campaign_id).group_by(Station.stationid) + ).select_from(Station).outerjoin(Sensor).filter(Station.campaignid == campaign_id) + + if published_only: + query = query.filter(Station.is_published == True) + + query = query.group_by(Station.stationid) total_count = query.count() return query.offset((page - 1) * limit).limit(limit).all(), total_count def get_stations( self, - campaign_id: Optional[int] = None, - active: Optional[bool] = None, - start_date: Optional[datetime] = None, + campaign_id: int | None = None, + active: bool | None = None, + start_date: datetime | None = None, page: int = 1, limit: int = 20, ) -> tuple[list[Station], int]: diff --git a/app/pytas/models/schemas.py b/app/pytas/models/schemas.py index c63261f..a91a40e 100644 --- a/app/pytas/models/schemas.py +++ b/app/pytas/models/schemas.py @@ -36,9 +36,9 @@ class PyTASAllocation(BaseModel): end: str status: str justification: str - decisionSummary: Optional[str] + decisionSummary: str | None dateRequested: str - dateReviewed: Optional[str] + dateReviewed: str | None computeRequested: int computeAllocated: int storageRequested: int diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py index 3cfb84e..0e58163 100644 --- a/app/services/campaign_service.py +++ b/app/services/campaign_service.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Optional import json from app.api.v1.schemas.station import SensorSummaryForStations, StationsListResponseItem from app.db.repositories.campaign_repository import CampaignRepository @@ -38,9 +39,10 @@ def get_campaigns_with_summary( sensor_variables: list[str] | None = None, page: int = 1, limit: int = 20, + published_only: bool = False, ) -> tuple[list[ListCampaignsResponseItem], int]: rows, total_count = self.campaign_repository.get_campaigns_and_summary( - allocations, bbox, start_date, end_date, sensor_variables, page, limit + allocations, bbox, start_date, end_date, sensor_variables, page, limit, published_only ) items: list[ListCampaignsResponseItem] = [] for row in rows: @@ -55,6 +57,8 @@ def get_campaigns_with_summary( start_date=row[0].startdate, end_date=row[0].enddate, allocation=row[0].allocation, + is_published=row[0].is_published, + published_at=row[0].published_at, location=Location( bbox_west=row[0].bbox_west, bbox_east=row[0].bbox_east, @@ -70,8 +74,8 @@ def get_campaigns_with_summary( items.append(item) return items, total_count - def get_campaign_with_summary(self, campaign_id: int) -> GetCampaignResponse | None: - campaign = self.campaign_repository.get_campaign(campaign_id) + def get_campaign_with_summary(self, campaign_id: int, published_only: bool = False) -> GetCampaignResponse | None: + campaign = self.campaign_repository.get_campaign(campaign_id, published_only=published_only) if not campaign: return None stations = [StationsListResponseItem( @@ -98,6 +102,8 @@ def get_campaign_with_summary(self, campaign_id: int) -> GetCampaignResponse | N start_date=campaign.startdate, end_date=campaign.enddate, allocation=campaign.allocation, + is_published=campaign.is_published, + published_at=campaign.published_at, location=Location( bbox_west=campaign.bbox_west, bbox_east=campaign.bbox_east, diff --git a/app/services/measurement_service.py b/app/services/measurement_service.py index 5ddac2f..67cd6b2 100644 --- a/app/services/measurement_service.py +++ b/app/services/measurement_service.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Optional import json from app.api.v1.schemas.measurement import AggregatedMeasurement, MeasurementCreateResponse, MeasurementIn, MeasurementItem, ListMeasurementsResponsePagination, MeasurementUpdate from app.db.repositories.measurement_repository import MeasurementRepository @@ -9,13 +10,26 @@ 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: 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, published_only: bool = False) -> ListMeasurementsResponsePagination: + # The repository does not enforce published filtering; accept the + # published_only parameter from the route and pass through to the + # repository for future use (or ignore if not implemented). 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] = [] for row in rows: + geometry_obj = None if row[1] is not None: + try: + geometry_obj = json.loads(row[1]) + except Exception as e: + # Malformed geometry in DB should not crash the endpoint. + # Log the issue and continue without geometry for this measurement. + # Use print for now to ensure it shows in simple server logs. + print(f"Failed to parse geometry for measurement {getattr(row[0], 'measurementid', '')}: {e} | raw: {row[1]}") + + if geometry_obj is not None: measurements.append(MeasurementItem( id=row[0].measurementid, value=row[0].measurementvalue, @@ -24,10 +38,21 @@ def list_measurements(self, sensor_id: int, start_date: datetime | None, end_dat variabletype=row[0].variabletype, variablename=row[0].variablename, sensorid=row[0].sensorid, - geometry=json.loads(row[1]) + geometry=geometry_obj )) else: - print(f"Measurement {row[0].measurementid} has no geometry {row[1]}") + # Still append the measurement but with no geometry so the + # frontend can render the time-series without spatial info. + measurements.append(MeasurementItem( + id=row[0].measurementid, + value=row[0].measurementvalue, + collectiontime=row[0].collectiontime, + description=row[0].description, + variabletype=row[0].variabletype, + variablename=row[0].variablename, + sensorid=row[0].sensorid, + geometry=None, + )) is_downsampled = downsample_threshold is not None and downsample_threshold > 2 # Apply LTTB downsampling if threshold is provided diff --git a/app/services/publishing_service.py b/app/services/publishing_service.py new file mode 100644 index 0000000..b4a167c --- /dev/null +++ b/app/services/publishing_service.py @@ -0,0 +1,255 @@ +from datetime import datetime +from typing import List, Optional +from sqlalchemy.orm import Session +from fastapi import HTTPException +import logging + +from app.db.models.campaign import Campaign +from app.db.models.station import Station +from app.db.models.sensor import Sensor +from app.db.models.measurement import Measurement + + +class PublishingService: + """Service to handle publishing operations with cascading logic.""" + + def __init__(self, db: Session): + self.db = db + + def publish_campaign(self, campaign_id: int, cascade: bool = False, force: bool = False) -> dict: + """Publish a campaign and optionally cascade to stations, sensors, and measurements.""" + campaign = self.db.query(Campaign).filter(Campaign.campaignid == campaign_id).first() + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + if campaign.is_published: + logging.info("publish_campaign: campaign %s already published=%s", campaign_id, campaign.is_published) + raise HTTPException(status_code=400, detail="Campaign is already published") + + # Publish the campaign + campaign.is_published = True + campaign.published_at = datetime.utcnow() + self.db.commit() + try: + # refresh instance from DB to ensure commit visibility + self.db.refresh(campaign) + except Exception: + pass + logging.info("publish_campaign: committed campaign %s published=%s published_at=%s", campaign_id, getattr(campaign, 'is_published', None), getattr(campaign, 'published_at', None)) + + cascaded_items = [] + + if cascade: + # Get all stations in this campaign + stations = self.db.query(Station).filter(Station.campaignid == campaign_id).all() + for station in stations: + if not station.is_published: + self._publish_station_internal(station, cascade=True) + cascaded_items.append(f"station:{station.stationid}") + + return { + "id": campaign_id, + "type": "campaign", + "is_published": True, + "published_at": campaign.published_at, + "cascaded_items": cascaded_items + } + + def publish_station(self, station_id: int, cascade: bool = False, force: bool = False) -> dict: + """Publish a station and optionally cascade to sensors and measurements.""" + station = self.db.query(Station).filter(Station.stationid == station_id).first() + if not station: + raise HTTPException(status_code=404, detail="Station not found") + + if station.is_published: + logging.info("publish_station: station %s already published=%s", station_id, station.is_published) + raise HTTPException(status_code=400, detail="Station is already published") + # Note: children (stations) are allowed to be published even if their parent + # campaign is not published. The previous behaviour required the parent + # campaign to be published unless force=True. That check has been removed + # so clients can publish stations independently of the campaign. + return self._publish_station_internal(station, cascade) + + def _publish_station_internal(self, station: Station, cascade: bool = False) -> dict: + """Internal method to publish a station.""" + logging.info("_publish_station_internal: publishing station %s (before: is_published=%s)", station.stationid, getattr(station, 'is_published', None)) + station.is_published = True + station.published_at = datetime.utcnow() + self.db.commit() + try: + self.db.refresh(station) + except Exception: + pass + logging.info("_publish_station_internal: committed station %s (after: is_published=%s published_at=%s)", station.stationid, getattr(station, 'is_published', None), getattr(station, 'published_at', None)) + + cascaded_items = [] + + if cascade: + # Get all sensors in this station + sensors = self.db.query(Sensor).filter(Sensor.stationid == station.stationid).all() + for sensor in sensors: + if not sensor.is_published: + self._publish_sensor_internal(sensor, cascade=True) + cascaded_items.append(f"sensor:{sensor.sensorid}") + + return { + "id": station.stationid, + "type": "station", + "is_published": True, + "published_at": station.published_at, + "cascaded_items": cascaded_items + } + + def publish_sensor(self, sensor_id: int, cascade: bool = False, force: bool = False) -> dict: + """Publish a sensor and optionally cascade to measurements.""" + sensor = self.db.query(Sensor).filter(Sensor.sensorid == sensor_id).first() + if not sensor: + raise HTTPException(status_code=404, detail="Sensor not found") + + if sensor.is_published: + raise HTTPException(status_code=400, detail="Sensor is already published") + # Children (sensors) may be published even if their parent station is not published. + # Previous behaviour enforced parent station published unless force=True; that has + # been removed to allow independent publishing of sensors. + return self._publish_sensor_internal(sensor, cascade) + + def _publish_sensor_internal(self, sensor: Sensor, cascade: bool = False) -> dict: + """Internal method to publish a sensor.""" + sensor.is_published = True + sensor.published_at = datetime.utcnow() + self.db.commit() + try: + self.db.refresh(sensor) + except Exception: + pass + cascaded_items = [] + + if cascade: + # Get all measurements for this sensor + measurements = self.db.query(Measurement).filter(Measurement.sensorid == sensor.sensorid).all() + for measurement in measurements: + if not measurement.is_published: + self._publish_measurement_internal(measurement) + cascaded_items.append(f"measurement:{measurement.measurementid}") + + return { + "id": sensor.sensorid, + "type": "sensor", + "is_published": True, + "published_at": sensor.published_at, + "cascaded_items": cascaded_items + } + + def publish_measurement(self, measurement_id: int, force: bool = False) -> dict: + """Publish a measurement.""" + measurement = self.db.query(Measurement).filter(Measurement.measurementid == measurement_id).first() + if not measurement: + raise HTTPException(status_code=404, detail="Measurement not found") + + if measurement.is_published: + raise HTTPException(status_code=400, detail="Measurement is already published") + # Allow publishing measurements even when parent sensor is not published. + # The parent-published precondition has been removed to permit independent + # publishing of measurements. + return self._publish_measurement_internal(measurement) + + def _publish_measurement_internal(self, measurement: Measurement) -> dict: + """Internal method to publish a measurement.""" + measurement.is_published = True + measurement.published_at = datetime.utcnow() + self.db.commit() + try: + self.db.refresh(measurement) + except Exception: + pass + return { + "id": measurement.measurementid, + "type": "measurement", + "is_published": True, + "published_at": measurement.published_at, + "cascaded_items": [] + } + + def unpublish_campaign(self, campaign_id: int) -> dict: + """Unpublish a campaign.""" + campaign = self.db.query(Campaign).filter(Campaign.campaignid == campaign_id).first() + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + if not campaign.is_published: + raise HTTPException(status_code=400, detail="Campaign is not published") + + campaign.is_published = False + campaign.published_at = None + self.db.commit() + + return { + "id": campaign_id, + "type": "campaign", + "is_published": False, + "published_at": None, + "cascaded_items": [] + } + + def unpublish_station(self, station_id: int) -> dict: + """Unpublish a station.""" + station = self.db.query(Station).filter(Station.stationid == station_id).first() + if not station: + raise HTTPException(status_code=404, detail="Station not found") + + if not station.is_published: + raise HTTPException(status_code=400, detail="Station is not published") + + station.is_published = False + station.published_at = None + self.db.commit() + + return { + "id": station_id, + "type": "station", + "is_published": False, + "published_at": None, + "cascaded_items": [] + } + + def unpublish_sensor(self, sensor_id: int) -> dict: + """Unpublish a sensor.""" + sensor = self.db.query(Sensor).filter(Sensor.sensorid == sensor_id).first() + if not sensor: + raise HTTPException(status_code=404, detail="Sensor not found") + + if not sensor.is_published: + raise HTTPException(status_code=400, detail="Sensor is not published") + + sensor.is_published = False + sensor.published_at = None + self.db.commit() + + return { + "id": sensor_id, + "type": "sensor", + "is_published": False, + "published_at": None, + "cascaded_items": [] + } + + def unpublish_measurement(self, measurement_id: int) -> dict: + """Unpublish a measurement.""" + measurement = self.db.query(Measurement).filter(Measurement.measurementid == measurement_id).first() + if not measurement: + raise HTTPException(status_code=404, detail="Measurement not found") + + if not measurement.is_published: + raise HTTPException(status_code=400, detail="Measurement is not published") + + measurement.is_published = False + measurement.published_at = None + self.db.commit() + + return { + "id": measurement_id, + "type": "measurement", + "is_published": False, + "published_at": None, + "cascaded_items": [] + } \ No newline at end of file diff --git a/app/services/sensor_service.py b/app/services/sensor_service.py index f6e8b4e..335b376 100644 --- a/app/services/sensor_service.py +++ b/app/services/sensor_service.py @@ -48,17 +48,17 @@ def partial_update_sensor(self, sensor_id: int, sensor: SensorUpdate) -> SensorC return SensorCreateResponse( id=response.sensorid, ) - def get_sensor(self, sensor_id: int) -> GetSensorResponse | None: - return self.sensor_repository.get_sensor(sensor_id) + def get_sensor(self, sensor_id: int, published_only: bool = False) -> GetSensorResponse | None: + return self.sensor_repository.get_sensor(sensor_id, published_only) def get_sensors( self, - station_id: Optional[int] = None, - variable_name: Optional[str] = None, - postprocess: Optional[bool] = None, + station_id: int | None = None, + variable_name: str | None = None, + postprocess: bool | None = None, page: int = 1, limit: int = 20, - sort_by: Optional[SortField] = None, + sort_by: SortField | None = None, sort_order: str = "asc" ) -> Tuple[List[SensorItem], int]: rows, total_count = self.sensor_repository.get_sensors( @@ -97,6 +97,10 @@ def get_sensors( last_measurement_value=statistics.last_measurement_value if statistics else None, stats_last_updated=statistics.stats_last_updated if statistics else None ) if statistics else None + , + # include publishing fields + is_published=getattr(sensor, 'is_published', False), + published_at=getattr(sensor, 'published_at', None) ) items.append(item) return items, total_count @@ -111,8 +115,9 @@ def get_sensors_by_station_id( alias: str | None = None, description_contains: str | None = None, postprocess: bool | None = None, - sort_by: Optional[SortField] = None, - sort_order: str = "asc" + sort_by: SortField | None = None, + sort_order: str = "asc", + published_only: bool = False ) -> Tuple[List[SensorItem], int]: rows, total_count = self.sensor_repository.get_sensors_by_station_id( station_id=station_id, @@ -124,7 +129,8 @@ def get_sensors_by_station_id( description_contains=description_contains, postprocess=postprocess, sort_by=sort_by, - sort_order=sort_order + sort_order=sort_order, + published_only=published_only ) items: List[SensorItem] = [] @@ -153,6 +159,10 @@ def get_sensors_by_station_id( last_measurement_value=statistics.last_measurement_value if statistics else None, stats_last_updated=statistics.stats_last_updated if statistics else None ) if statistics else None + , + # include publishing fields + is_published=getattr(sensor, 'is_published', False), + published_at=getattr(sensor, 'published_at', None) ) items.append(item) return items, total_count @@ -175,7 +185,7 @@ def bulk_create_measurements(self, measurements: List[MeasurementIn], sensor_id: self.measurement_repository.bulk_create_measurements(measurements, sensor_id) return True - def get_latest_measurement(self, sensor_id: int) -> Optional[datetime]: + def get_latest_measurement(self, sensor_id: int) -> datetime | None: measurement = self.measurement_repository.get_latest_measurement_by_sensor_id(sensor_id) return measurement.collectiontime if measurement else None diff --git a/app/services/station_service.py b/app/services/station_service.py index 311cfc9..5e4bf4d 100644 --- a/app/services/station_service.py +++ b/app/services/station_service.py @@ -25,8 +25,8 @@ def partial_update_station(self, station_id: int, station: StationUpdate) -> Sta return StationCreateResponse( id=response.campaignid, ) - 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) + def get_stations_with_summary(self, campaign_id: int, page: int = 1, limit: int = 20, published_only: bool = False) -> tuple[list[StationItemWithSummary], int]: + rows, total_count = self.station_repository.list_stations_and_summary(campaign_id, page, limit, published_only) stations : list[StationItemWithSummary] = [] for row in rows: sensor_types : list[str | None] = row[2] @@ -41,12 +41,18 @@ def get_stations_with_summary(self, campaign_id: int, page: int = 1, limit: int sensor_variables=[x for x in sensor_variables if x is not None], sensor_count=row[1] ) + # include publishing state from DB + try: + station.is_published = getattr(row[0], 'is_published', False) + station.published_at = getattr(row[0], 'published_at', None) + except Exception: + pass stations.append(station) return stations, total_count - def get_station(self, station_id: int) -> GetStationResponse | None: - row = self.station_repository.get_station(station_id) + def get_station(self, station_id: int, published_only: bool = False) -> GetStationResponse | None: + row = self.station_repository.get_station(station_id, published_only) geometry = {} if row: try: @@ -72,7 +78,13 @@ def get_station(self, station_id: int) -> GetStationResponse | None: description=sensor.description, postprocess=sensor.postprocess, variablename=sensor.variablename, - ) for sensor in row.sensors] + # surface sensor publishing state + is_published=getattr(sensor, 'is_published', False), + published_at=getattr(sensor, 'published_at', None), + ) for sensor in row.sensors], + # surface station publishing state + is_published=getattr(row, 'is_published', False), + published_at=getattr(row, 'published_at', None), ) def delete_station_sensors(self, station_id: int) -> bool: return self.station_repository.delete_station_sensors(station_id) diff --git a/fix_optional.py b/fix_optional.py new file mode 100644 index 0000000..9f07e10 --- /dev/null +++ b/fix_optional.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +import os +import re + +def fix_optional_in_file(file_path): + """Fix Optional[Type] to Type | None in a Python file.""" + try: + with open(file_path, 'r') as f: + content = f.read() + + # Track if any changes were made + original_content = content + + # Replace Optional[Type] with Type | None + # This regex handles nested brackets like Optional[List[str]] + def replace_optional(match): + inner_type = match.group(1) + return f"{inner_type} | None" + + # Handle nested Optional types + while True: + new_content = re.sub(r'Optional\[([^\[\]]+(?:\[[^\[\]]*\])*[^\[\]]*)\]', replace_optional, content) + if new_content == content: + break + content = new_content + + # Write back if changed + if content != original_content: + with open(file_path, 'w') as f: + f.write(content) + print(f"Fixed: {file_path}") + return True + else: + print(f"No changes needed: {file_path}") + return False + + except Exception as e: + print(f"Error processing {file_path}: {e}") + return False + +def main(): + # Files that need fixing + files_to_fix = [ + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/services/measurement_service.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/services/sensor_service.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/services/campaign_service.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/routes/campaigns/root.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/routes/campaigns/campaign_station_sensor_measurements.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/routes/campaigns/campaign_stations.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/routes/campaigns/campaign_station_sensors.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/schemas/upload_csv_validators.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/schemas/campaign.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/schemas/measurement.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/repositories/measurement_repository.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/repositories/sensor_repository.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/repositories/campaign_repository.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/pytas/models/schemas.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/repositories/station_repository.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/models/measurement.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/models/sensor.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/models/station.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/models/campaign.py", + "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/models/sensor_statistics.py" + ] + + fixed_count = 0 + for file_path in files_to_fix: + if os.path.exists(file_path): + if fix_optional_in_file(file_path): + fixed_count += 1 + else: + print(f"File not found: {file_path}") + + print(f"\nFixed {fixed_count} files") + +if __name__ == "__main__": + main() \ 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..8cde64a 100644 --- a/tests/api/test_campaign_station_sensors.py +++ b/tests/api/test_campaign_station_sensors.py @@ -118,12 +118,12 @@ 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 diff --git a/tests/test_measurement_service.py b/tests/test_measurement_service.py index d7e4e10..6a2b38b 100644 --- a/tests/test_measurement_service.py +++ b/tests/test_measurement_service.py @@ -1,4 +1,5 @@ from unittest.mock import patch, Mock +from typing import Optional from geoalchemy2 import Geometry import pytest from datetime import datetime, timedelta @@ -8,7 +9,7 @@ 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]} From d3f37936c34924add7f1ee45d9eb018b4c6cf23f Mon Sep 17 00:00:00 2001 From: Will Mobley Date: Tue, 7 Oct 2025 14:59:10 -0500 Subject: [PATCH 3/3] update tests and fix mypi errors --- .../campaign_station_sensor_measurements.py | 13 +--- .../campaigns/campaign_station_sensors.py | 71 +++++++---------- app/api/v1/routes/campaigns/permissions.py | 3 +- app/api/v1/schemas/measurement.py | 2 +- app/services/publishing_service.py | 24 +++--- fix_optional.py | 77 ------------------- tests/api/test_campaign_station_sensors.py | 16 +++- tests/test_campaign_station_routes.py | 25 +++++- tests/test_campaign_station_sensor_routes.py | 7 +- 9 files changed, 82 insertions(+), 156 deletions(-) delete mode 100644 fix_optional.py 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 7e8c4ac..d486299 100644 --- a/app/api/v1/routes/campaigns/campaign_station_sensor_measurements.py +++ b/app/api/v1/routes/campaigns/campaign_station_sensor_measurements.py @@ -44,7 +44,7 @@ async def get_sensor_measurements( end_date: datetime | None = None, min_measurement_value: float | None = None, max_measurement_value: float | None = None, - current_user: User | None = Depends(get_current_user_optional), + current_user: User = Depends(get_current_user), limit: int = 1000, page: int = 1, downsample_threshold: int | None = None, @@ -53,14 +53,9 @@ async def get_sensor_measurements( measurement_repository = MeasurementRepository(db) measurement_service = MeasurementService(measurement_repository) - if current_user: - # Authenticated user - check allocation permissions, show all measurements - if not check_allocation_permission(current_user, campaign_id): - raise HTTPException(status_code=404, detail="Allocation is incorrect") - 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, published_only=False) - else: - # Unauthenticated user - show only published measurements - 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, published_only=True) + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + 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, published_only=False) @router.get("/measurements/confidence-intervals", response_model=list[AggregatedMeasurement]) async def get_measurements_with_confidence_intervals( diff --git a/app/api/v1/routes/campaigns/campaign_station_sensors.py b/app/api/v1/routes/campaigns/campaign_station_sensors.py index 35d47d2..ee4df54 100644 --- a/app/api/v1/routes/campaigns/campaign_station_sensors.py +++ b/app/api/v1/routes/campaigns/campaign_station_sensors.py @@ -32,55 +32,38 @@ async def list_sensors( 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)"), postprocess: bool | None = Query(None, description="Filter sensors by postprocess flag"), - current_user: User | None = Depends(get_current_user_optional), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), sort_by: SortField | None = Query(None, description="Sort sensors by field"), sort_order: str = Query("asc", description="Sort order (asc or desc)"), ) -> ListSensorsResponsePagination: + if not current_user: + raise HTTPException(status_code=401, detail="Not authenticated") sensor_service = SensorService( sensor_repository=SensorRepository(db), measurement_repository=MeasurementRepository(db) ) - - if current_user: - # Authenticated user - check allocation permissions, show all sensors - if not check_allocation_permission(current_user, campaign_id): - raise HTTPException(status_code=404, detail="Allocation is incorrect") - items, total_count = sensor_service.get_sensors_by_station_id( - station_id=station_id, - page=page, - limit=limit, - variable_name=variable_name, - units=units, - alias=alias, - description_contains=description_contains, - postprocess=postprocess, - sort_by=sort_by, - sort_order=sort_order, - published_only=False - ) - else: - # Unauthenticated user - show only published sensors in published stations/campaigns - items, total_count = sensor_service.get_sensors_by_station_id( - station_id=station_id, - page=page, - limit=limit, - variable_name=variable_name, - units=units, - alias=alias, - description_contains=description_contains, - postprocess=postprocess, - sort_by=sort_by, - sort_order=sort_order, - published_only=True - ) - + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") + items, total_count = sensor_service.get_sensors_by_station_id( + station_id=station_id, + page=page, + limit=limit, + variable_name=variable_name, + units=units, + alias=alias, + description_contains=description_contains, + postprocess=postprocess, + sort_by=sort_by, + sort_order=sort_order, + published_only=False + ) return ListSensorsResponsePagination( items=items, total=total_count, page=page, size=limit, - pages=(total_count + limit - 1) // limit, + pages=(total_count + limit - 1) // limit if limit > 0 else 0, ) @router.get("/sensors/{sensor_id}") @@ -88,22 +71,20 @@ async def get_sensor( station_id: int, sensor_id: int, campaign_id: int, - current_user: User | None = Depends(get_current_user_optional), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ) -> GetSensorResponse: + if not current_user: + raise HTTPException(status_code=401, detail="Not authenticated") + sensor_service = SensorService( sensor_repository=SensorRepository(db), measurement_repository=MeasurementRepository(db) ) + if not check_allocation_permission(current_user, campaign_id): + raise HTTPException(status_code=404, detail="Allocation is incorrect") - if current_user: - # Authenticated user - check allocation permissions, show all sensors - if not check_allocation_permission(current_user, campaign_id): - raise HTTPException(status_code=404, detail="Sensor not found") - response = sensor_service.get_sensor(sensor_id, published_only=False) - else: - # Unauthenticated user - show only if published and parent entities are published - response = sensor_service.get_sensor(sensor_id, published_only=True) + response = sensor_service.get_sensor(sensor_id, published_only=False) if response is None: raise HTTPException(status_code=404, detail="Sensor not found") diff --git a/app/api/v1/routes/campaigns/permissions.py b/app/api/v1/routes/campaigns/permissions.py index 7dbf186..4e29987 100644 --- a/app/api/v1/routes/campaigns/permissions.py +++ b/app/api/v1/routes/campaigns/permissions.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session +from typing import Any from app.api.dependencies.auth import get_current_user from app.db.session import get_db @@ -14,7 +15,7 @@ def get_campaign_permissions( campaign_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), -) -> dict: +) -> dict[str, Any]: """ Get user permissions for a specific campaign. Returns what actions the current user can perform on the campaign. diff --git a/app/api/v1/schemas/measurement.py b/app/api/v1/schemas/measurement.py index 440b6cd..8bccead 100644 --- a/app/api/v1/schemas/measurement.py +++ b/app/api/v1/schemas/measurement.py @@ -26,7 +26,7 @@ class MeasurementCreateResponse(BaseModel): class MeasurementItem(BaseModel): id: int value: float - geometry: Point + geometry: Point | None collectiontime: datetime sensorid: int | None = None variablename: str | None = None # modified diff --git a/app/services/publishing_service.py b/app/services/publishing_service.py index b4a167c..493bb5b 100644 --- a/app/services/publishing_service.py +++ b/app/services/publishing_service.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Any from sqlalchemy.orm import Session from fastapi import HTTPException import logging @@ -16,7 +16,7 @@ class PublishingService: def __init__(self, db: Session): self.db = db - def publish_campaign(self, campaign_id: int, cascade: bool = False, force: bool = False) -> dict: + def publish_campaign(self, campaign_id: int, cascade: bool = False, force: bool = False) -> dict[str, Any]: """Publish a campaign and optionally cascade to stations, sensors, and measurements.""" campaign = self.db.query(Campaign).filter(Campaign.campaignid == campaign_id).first() if not campaign: @@ -55,7 +55,7 @@ def publish_campaign(self, campaign_id: int, cascade: bool = False, force: bool "cascaded_items": cascaded_items } - def publish_station(self, station_id: int, cascade: bool = False, force: bool = False) -> dict: + def publish_station(self, station_id: int, cascade: bool = False, force: bool = False) -> dict[str, Any]: """Publish a station and optionally cascade to sensors and measurements.""" station = self.db.query(Station).filter(Station.stationid == station_id).first() if not station: @@ -70,7 +70,7 @@ def publish_station(self, station_id: int, cascade: bool = False, force: bool = # so clients can publish stations independently of the campaign. return self._publish_station_internal(station, cascade) - def _publish_station_internal(self, station: Station, cascade: bool = False) -> dict: + def _publish_station_internal(self, station: Station, cascade: bool = False) -> dict[str, Any]: """Internal method to publish a station.""" logging.info("_publish_station_internal: publishing station %s (before: is_published=%s)", station.stationid, getattr(station, 'is_published', None)) station.is_published = True @@ -100,7 +100,7 @@ def _publish_station_internal(self, station: Station, cascade: bool = False) -> "cascaded_items": cascaded_items } - def publish_sensor(self, sensor_id: int, cascade: bool = False, force: bool = False) -> dict: + def publish_sensor(self, sensor_id: int, cascade: bool = False, force: bool = False) -> dict[str, Any]: """Publish a sensor and optionally cascade to measurements.""" sensor = self.db.query(Sensor).filter(Sensor.sensorid == sensor_id).first() if not sensor: @@ -113,7 +113,7 @@ def publish_sensor(self, sensor_id: int, cascade: bool = False, force: bool = Fa # been removed to allow independent publishing of sensors. return self._publish_sensor_internal(sensor, cascade) - def _publish_sensor_internal(self, sensor: Sensor, cascade: bool = False) -> dict: + def _publish_sensor_internal(self, sensor: Sensor, cascade: bool = False) -> dict[str, Any]: """Internal method to publish a sensor.""" sensor.is_published = True sensor.published_at = datetime.utcnow() @@ -140,7 +140,7 @@ def _publish_sensor_internal(self, sensor: Sensor, cascade: bool = False) -> dic "cascaded_items": cascaded_items } - def publish_measurement(self, measurement_id: int, force: bool = False) -> dict: + def publish_measurement(self, measurement_id: int, force: bool = False) -> dict[str, Any]: """Publish a measurement.""" measurement = self.db.query(Measurement).filter(Measurement.measurementid == measurement_id).first() if not measurement: @@ -153,7 +153,7 @@ def publish_measurement(self, measurement_id: int, force: bool = False) -> dict: # publishing of measurements. return self._publish_measurement_internal(measurement) - def _publish_measurement_internal(self, measurement: Measurement) -> dict: + def _publish_measurement_internal(self, measurement: Measurement) -> dict[str, Any]: """Internal method to publish a measurement.""" measurement.is_published = True measurement.published_at = datetime.utcnow() @@ -170,7 +170,7 @@ def _publish_measurement_internal(self, measurement: Measurement) -> dict: "cascaded_items": [] } - def unpublish_campaign(self, campaign_id: int) -> dict: + def unpublish_campaign(self, campaign_id: int) -> dict[str, Any]: """Unpublish a campaign.""" campaign = self.db.query(Campaign).filter(Campaign.campaignid == campaign_id).first() if not campaign: @@ -191,7 +191,7 @@ def unpublish_campaign(self, campaign_id: int) -> dict: "cascaded_items": [] } - def unpublish_station(self, station_id: int) -> dict: + def unpublish_station(self, station_id: int) -> dict[str, Any]: """Unpublish a station.""" station = self.db.query(Station).filter(Station.stationid == station_id).first() if not station: @@ -212,7 +212,7 @@ def unpublish_station(self, station_id: int) -> dict: "cascaded_items": [] } - def unpublish_sensor(self, sensor_id: int) -> dict: + def unpublish_sensor(self, sensor_id: int) -> dict[str, Any]: """Unpublish a sensor.""" sensor = self.db.query(Sensor).filter(Sensor.sensorid == sensor_id).first() if not sensor: @@ -233,7 +233,7 @@ def unpublish_sensor(self, sensor_id: int) -> dict: "cascaded_items": [] } - def unpublish_measurement(self, measurement_id: int) -> dict: + def unpublish_measurement(self, measurement_id: int) -> dict[str, Any]: """Unpublish a measurement.""" measurement = self.db.query(Measurement).filter(Measurement.measurementid == measurement_id).first() if not measurement: diff --git a/fix_optional.py b/fix_optional.py deleted file mode 100644 index 9f07e10..0000000 --- a/fix_optional.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -import os -import re - -def fix_optional_in_file(file_path): - """Fix Optional[Type] to Type | None in a Python file.""" - try: - with open(file_path, 'r') as f: - content = f.read() - - # Track if any changes were made - original_content = content - - # Replace Optional[Type] with Type | None - # This regex handles nested brackets like Optional[List[str]] - def replace_optional(match): - inner_type = match.group(1) - return f"{inner_type} | None" - - # Handle nested Optional types - while True: - new_content = re.sub(r'Optional\[([^\[\]]+(?:\[[^\[\]]*\])*[^\[\]]*)\]', replace_optional, content) - if new_content == content: - break - content = new_content - - # Write back if changed - if content != original_content: - with open(file_path, 'w') as f: - f.write(content) - print(f"Fixed: {file_path}") - return True - else: - print(f"No changes needed: {file_path}") - return False - - except Exception as e: - print(f"Error processing {file_path}: {e}") - return False - -def main(): - # Files that need fixing - files_to_fix = [ - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/services/measurement_service.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/services/sensor_service.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/services/campaign_service.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/routes/campaigns/root.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/routes/campaigns/campaign_station_sensor_measurements.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/routes/campaigns/campaign_stations.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/routes/campaigns/campaign_station_sensors.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/schemas/upload_csv_validators.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/schemas/campaign.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/api/v1/schemas/measurement.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/repositories/measurement_repository.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/repositories/sensor_repository.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/repositories/campaign_repository.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/pytas/models/schemas.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/repositories/station_repository.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/models/measurement.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/models/sensor.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/models/station.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/models/campaign.py", - "/Users/wmobley/Documents/GitHub/upstream/upstream-docker/app/db/models/sensor_statistics.py" - ] - - fixed_count = 0 - for file_path in files_to_fix: - if os.path.exists(file_path): - if fix_optional_in_file(file_path): - fixed_count += 1 - else: - print(f"File not found: {file_path}") - - print(f"\nFixed {fixed_count} files") - -if __name__ == "__main__": - main() \ 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 8cde64a..b89f5b2 100644 --- a/tests/api/test_campaign_station_sensors.py +++ b/tests/api/test_campaign_station_sensors.py @@ -40,7 +40,8 @@ def sample_sensors() -> list[Sensor]: postprocess=True, postprocessscript="temp * 1.8 + 32", units="°F", - variablename="temperature" + variablename="temperature", + is_published=True ), Sensor( sensorid=2, @@ -50,7 +51,8 @@ def sample_sensors() -> list[Sensor]: postprocess=False, postprocessscript=None, units="%", - variablename="humidity" + variablename="humidity", + is_published=True ), Sensor( sensorid=3, @@ -60,7 +62,8 @@ def sample_sensors() -> list[Sensor]: postprocess=True, postprocessscript="pressure * 0.000145038", units="psi", - variablename="pressure" + variablename="pressure", + is_published=True ) ] @@ -124,7 +127,8 @@ def get_sensors_by_station_id_mock( description_contains: Optional[str] = None, postprocess: Optional[bool] = None, sort_by: Optional[SortField] = None, - sort_order: str = "asc" + sort_order: str = "asc", + published_only: bool = False ) -> Tuple[List[Tuple[Sensor, SensorStatistics]], int]: # Filter sensors based on parameters filtered_sensors = sample_sensors.copy() @@ -133,6 +137,10 @@ def get_sensors_by_station_id_mock( # Apply station_id filter filtered_sensors = [s for s in filtered_sensors if s.stationid == station_id] + # Apply published_only filter + if published_only: + filtered_sensors = [s for s in filtered_sensors if getattr(s, 'is_published', False)] + # Apply variable_name filter if variable_name: filtered_sensors = [s for s in filtered_sensors if s.variablename and variable_name.lower() in s.variablename.lower()] diff --git a/tests/test_campaign_station_routes.py b/tests/test_campaign_station_routes.py index 25f191b..1b7d593 100644 --- a/tests/test_campaign_station_routes.py +++ b/tests/test_campaign_station_routes.py @@ -6,8 +6,24 @@ from app.main import app from app.api.v1.schemas.station import StationCreate, StationUpdate, StationCreateResponse, GetStationResponse, StationItemWithSummary from app.api.v1.schemas.user import User -from app.api.dependencies.auth import get_current_user +from app.api.dependencies.auth import get_current_user, get_current_user_optional from app.db.session import get_db +import jwt +from datetime import datetime, timedelta + +TEST_JWT_SECRET = "test-secret-key" +TEST_JWT_ALGORITHM = "HS256" + +@pytest.fixture +def auth_headers() -> dict[str, str]: + """Create authentication headers with a JWT token""" + payload = { + "sub": "testuser", + "exp": datetime.utcnow() + timedelta(minutes=30), + "iat": datetime.utcnow(), + } + token = jwt.encode(payload, TEST_JWT_SECRET, algorithm=TEST_JWT_ALGORITHM) + return {"Authorization": f"Bearer {token}"} # Mock data for testing MOCK_USER = User( @@ -81,6 +97,7 @@ def client_with_auth(): 'SECRET_KEY': 'test-secret-key', }): app.dependency_overrides[get_current_user] = override_get_current_user + app.dependency_overrides[get_current_user_optional] = override_get_current_user app.dependency_overrides[get_db] = override_get_db client = TestClient(app) yield client @@ -138,7 +155,7 @@ def test_list_stations_success(self, client_with_auth): assert data["total"] == 1 assert len(data["items"]) == 1 assert data["items"][0]["id"] == MOCK_STATION_ITEM_SUMMARY["id"] - mock_list.assert_called_once_with(self.campaign_id, 1, 20) + mock_list.assert_called_once_with(self.campaign_id, 1, 20, published_only=False) def test_list_stations_permission_denied(self, client_with_auth): with patch('app.api.v1.routes.campaigns.campaign_stations.check_allocation_permission', return_value=False): @@ -154,7 +171,7 @@ def test_get_station_success(self, client_with_auth): response = client_with_auth.get(f"/api/v1/campaigns/{self.campaign_id}/stations/{self.station_id}") assert response.status_code == 200 assert response.json()["id"] == self.station_id - mock_get.assert_called_once_with(self.station_id) + mock_get.assert_called_once_with(self.station_id, published_only=False) def test_get_station_not_found(self, client_with_auth): with patch('app.api.v1.routes.campaigns.campaign_stations.check_allocation_permission', return_value=True), \ @@ -162,7 +179,7 @@ def test_get_station_not_found(self, client_with_auth): response = client_with_auth.get(f"/api/v1/campaigns/{self.campaign_id}/stations/{self.station_id}") assert response.status_code == 404 assert response.json()["detail"] == "Station not found" - mock_get.assert_called_once_with(self.station_id) + mock_get.assert_called_once_with(self.station_id, published_only=False) # DELETE /campaigns/{campaign_id}/stations # Note: The route function is named delete_sensor, but it deletes campaign stations. diff --git a/tests/test_campaign_station_sensor_routes.py b/tests/test_campaign_station_sensor_routes.py index 8cc1dcb..1f2ac59 100644 --- a/tests/test_campaign_station_sensor_routes.py +++ b/tests/test_campaign_station_sensor_routes.py @@ -124,7 +124,8 @@ def test_list_sensors_success(self, client_with_auth): description_contains=None, postprocess=None, sort_by=SortField.ALIAS, - sort_order="desc" + sort_order="desc", + published_only=False ) def test_list_sensors_permission_denied(self, client_with_auth): @@ -147,7 +148,7 @@ def test_get_sensor_success(self, client_with_auth): ) assert response.status_code == 200 assert response.json()["id"] == self.sensor_id - mock_get.assert_called_once_with(self.sensor_id) + mock_get.assert_called_once_with(self.sensor_id, published_only=False) def test_get_sensor_not_found(self, client_with_auth): with patch('app.api.v1.routes.campaigns.campaign_station_sensors.check_allocation_permission', return_value=True), \ @@ -157,7 +158,7 @@ def test_get_sensor_not_found(self, client_with_auth): ) assert response.status_code == 404 assert response.json()["detail"] == "Sensor not found" - mock_get.assert_called_once_with(self.sensor_id) + mock_get.assert_called_once_with(self.sensor_id, published_only=False) # DELETE /campaigns/{campaign_id}/stations/{station_id}/sensors # Note: The route function is named delete_sensor, but it deletes all sensors for a station.