Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions alembic/versions/467dfa27d7ea_add_publishing_fields.py
Original file line number Diff line number Diff line change
@@ -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')
20 changes: 20 additions & 0 deletions app/api/dependencies/auth.py
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions app/api/v1/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
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

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -48,11 +50,12 @@ async def get_sensor_measurements(
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 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(
Expand Down Expand Up @@ -127,4 +130,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
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)
72 changes: 58 additions & 14 deletions app/api/v1/routes/campaigns/campaign_station_sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -29,20 +31,20 @@ 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"),
postprocess: bool | None = Query(None, description="Filter sensors by postprocess flag"),
current_user: User = Depends(get_current_user),
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")

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")
items, total_count = sensor_service.get_sensors_by_station_id(
station_id=station_id,
page=page,
Expand All @@ -53,15 +55,15 @@ async def list_sensors(
description_contains=description_contains,
postprocess=postprocess,
sort_by=sort_by,
sort_order=sort_order
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}")
Expand All @@ -72,15 +74,18 @@ async def get_sensor(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> GetSensorResponse:
if not check_allocation_permission(current_user, campaign_id):
raise HTTPException(status_code=404, detail="Allocation is incorrect")
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")

response = sensor_service.get_sensor(sensor_id, published_only=False)

response = sensor_service.get_sensor(sensor_id)
if response is None:
raise HTTPException(status_code=404, detail="Sensor not found")
return response
Expand Down Expand Up @@ -201,4 +206,43 @@ def delete_sensor_sensor_id(
if not success:
raise HTTPException(status_code=404, detail="Sensor not found")

return Response(status_code=204)
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)
Loading