Skip to content
Merged
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
43 changes: 30 additions & 13 deletions app/container.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import sqlalchemy.ext.asyncio
from fastapi import Depends

from app.deps.ai_deps import get_face_embedding_service
from app.infra.database import get_db
from app.infra.redis import RedisClient
from db.generated import user as user_queries
from db.generated import session as session_queries
from db.generated import devices as device_queries
from db.generated import stuff_user as staff_user_queries
from db.generated import staff_drive_connections as staff_drive_queries
from app.service.device import DeviceService
from app.service.face_embedding import FaceEmbeddingService
from app.deps.ai_deps import get_face_embedding_service
from app.service.users import AuthService
from app.service.session import SessionService
from app.service.device import DeviceService
from app.service.staff_drive import StaffDriveService
from app.service.staff_notifications import StaffNotificationsService
from app.service.staff_user import StaffUserService


from app.service.upload_requests import UploadRequestsService
from app.service.users import AuthService
from db.generated import devices as device_queries
from db.generated import photos as photo_queries
from db.generated import session as session_queries
from db.generated import staff_drive_connections as staff_drive_queries
from db.generated import staff_notifications as staff_notification_queries
from db.generated import stuff_user as staff_user_queries
from db.generated import upload_request_photos as upload_request_photo_queries
from db.generated import upload_requests as upload_request_queries
from db.generated import user as user_queries

class Container:

def __init__(
self,
conn: sqlalchemy.ext.asyncio.AsyncConnection,
Expand All @@ -34,6 +38,10 @@ def __init__(
self.device_querier = device_queries.AsyncQuerier(conn)
self.staff_user_querier = staff_user_queries.AsyncQuerier(conn)
self.staff_drive_querier = staff_drive_queries.AsyncQuerier(conn)
self.upload_request_querier = upload_request_queries.AsyncQuerier(conn)
self.upload_request_photo_querier = upload_request_photo_queries.AsyncQuerier(conn)
self.photo_querier = photo_queries.AsyncQuerier(conn)
self.staff_notification_querier = staff_notification_queries.AsyncQuerier(conn)

# services
self.session_service = SessionService()
Expand All @@ -60,13 +68,22 @@ def __init__(
redis=self.redis,
)

self.staff_notifications_service = StaffNotificationsService(
notification_querier=self.staff_notification_querier,
)

self.upload_requests_service = UploadRequestsService(
upload_request_querier=self.upload_request_querier,
upload_request_photo_querier=self.upload_request_photo_querier,
photo_querier=self.photo_querier,
staff_notifications_service=self.staff_notifications_service,
)

self.staff_user_service = StaffUserService()
self.staff_user_service.init(
staff_user_querier=self.staff_user_querier,
)



async def get_container(
conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db),
) -> Container:
Expand Down
18 changes: 17 additions & 1 deletion app/deps/staff_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@

from app.container import Container, get_container
from app.core.exceptions import AppException
from db.generated.models import StaffUser
from db.generated.models import StaffRole, StaffUser


def _role_value(role: object) -> str:
return getattr(role, "value", str(role))


async def get_current_staff_user(
Expand All @@ -22,3 +26,15 @@ async def get_current_staff_user(
raise AppException.not_found("Staff user not found")

return staff_user


def ensure_multi_team_lead_staff(current_staff_user: StaffUser) -> StaffUser:
if _role_value(current_staff_user.role) != StaffRole.MULTI_TEAM_LEAD.value:
raise AppException.forbidden("Multi team lead access required")
return current_staff_user


async def require_multi_team_lead_staff(
current_staff_user: Annotated[StaffUser, Depends(get_current_staff_user)],
) -> StaffUser:
return ensure_multi_team_lead_staff(current_staff_user)
1 change: 1 addition & 0 deletions app/infra/redis.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Any

from redis.asyncio import Redis

from app.core.constant import RedisKey


Expand Down
36 changes: 36 additions & 0 deletions app/router/staff/notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends

from app.container import Container, get_container
from app.deps.staff_auth import get_current_staff_user
from app.schema.request.staff.notifications import MarkStaffNotificationsReadRequest
from app.schema.response.staff.notifications import (
StaffNotificationListResponse,
)
from db.generated.models import StaffUser


router = APIRouter(prefix="/notifications", tags=["staff-notifications"])


@router.get("", response_model=StaffNotificationListResponse)
async def list_staff_notifications(
current_staff_user: StaffUser = Depends(get_current_staff_user),
container: Container = Depends(get_container),
) -> StaffNotificationListResponse:
notifications = await container.staff_notifications_service.list_notifications(
staff_user_id=current_staff_user.id
)
return StaffNotificationListResponse.from_models(notifications)


@router.post("/read", response_model=StaffNotificationListResponse)
async def mark_staff_notifications_as_read(
req: MarkStaffNotificationsReadRequest,
current_staff_user: StaffUser = Depends(get_current_staff_user),
container: Container = Depends(get_container),
) -> StaffNotificationListResponse:
notifications = await container.staff_notifications_service.mark_many_as_read(
notification_ids=req.notification_ids,
staff_user_id=current_staff_user.id,
)
return StaffNotificationListResponse.from_models(notifications)
81 changes: 81 additions & 0 deletions app/router/staff/uploads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from typing import Literal
from uuid import UUID

from fastapi import APIRouter, Depends, Query

from app.container import Container, get_container
from app.deps.staff_auth import (
get_current_staff_user,
require_multi_team_lead_staff,
)
from app.schema.request.staff.uploads import (
CreateUploadRequestRequest,
RejectUploadRequestRequest,
)
from app.schema.response.staff.uploads import (
UploadRequestListResponse,
UploadRequestSchema,
)
from db.generated.models import StaffUser, UploadRequestStatus


router = APIRouter(prefix="/uploads", tags=["staff-uploads"])


@router.post("/request", response_model=UploadRequestSchema)
async def create_upload_request(
req: CreateUploadRequestRequest,
current_staff_user: StaffUser = Depends(get_current_staff_user),
container: Container = Depends(get_container),
) -> UploadRequestSchema:
upload_request = await container.upload_requests_service.create_request(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate the format and size for the pic

event_id=req.event_id,
photos=req.to_inputs(),
requested_by=current_staff_user,
)
return UploadRequestSchema.from_models(upload_request.request, upload_request.photos)


@router.get("", response_model=UploadRequestListResponse)
async def list_upload_requests(
scope: Literal["my", "all"] = Query(default="my"),
status: UploadRequestStatus | None = Query(default=None),
current_staff_user: StaffUser = Depends(get_current_staff_user),
container: Container = Depends(get_container),
) -> UploadRequestListResponse:
requests = await container.upload_requests_service.list_requests(
current_staff_user=current_staff_user,
scope=scope,
status=status.value if status is not None else None,
)
return UploadRequestListResponse.from_models(
[(item.request, item.photos) for item in requests]
)


@router.post("/{request_id}/approve", response_model=UploadRequestSchema)
async def approve_upload_request(
request_id: UUID,
current_staff_user: StaffUser = Depends(require_multi_team_lead_staff),
container: Container = Depends(get_container),
) -> UploadRequestSchema:
upload_request = await container.upload_requests_service.approve_request(
request_id=request_id,
approved_by=current_staff_user,
)
return UploadRequestSchema.from_models(upload_request.request, upload_request.photos)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

those three are the same endpoint with one filter approaved and rejected and pending


@router.post("/{request_id}/reject", response_model=UploadRequestSchema)
async def reject_upload_request(
request_id: UUID,
req: RejectUploadRequestRequest,
current_staff_user: StaffUser = Depends(require_multi_team_lead_staff),
container: Container = Depends(get_container),
) -> UploadRequestSchema:
upload_request = await container.upload_requests_service.reject_request(
request_id=request_id,
approved_by=current_staff_user,
reason=req.reason,
)
return UploadRequestSchema.from_models(upload_request.request, upload_request.photos)
13 changes: 13 additions & 0 deletions app/schema/dto/staff/uploads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from dataclasses import dataclass
from datetime import datetime


@dataclass(frozen=True)
class UploadPhotoInput:
drive_file_id: str
file_name: str
mime_type: str
size_bytes: int
taken_at: datetime | None
day_number: int | None
visibility: str
7 changes: 7 additions & 0 deletions app/schema/request/staff/notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from uuid import UUID

from pydantic import BaseModel, Field


class MarkStaffNotificationsReadRequest(BaseModel):
notification_ids: list[UUID] = Field(min_length=1, max_length=100)
86 changes: 86 additions & 0 deletions app/schema/request/staff/uploads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from datetime import datetime
from pathlib import PurePath
from typing import ClassVar

from pydantic import BaseModel, Field, field_validator, model_validator
from uuid import UUID

from app.schema.dto.staff.uploads import UploadPhotoInput


MAX_UPLOAD_BATCH_SIZE = 20
MAX_UPLOAD_PHOTO_SIZE_BYTES = 20 * 1024 * 1024
ALLOWED_IMAGE_MIME_TYPES: dict[str, tuple[str, ...]] = {
"image/jpeg": (".jpg", ".jpeg"),
"image/png": (".png",),
"image/webp": (".webp",),
}


class CreateUploadRequestPhotoRequest(BaseModel):
drive_file_id: str = Field(min_length=1, max_length=255)
file_name: str = Field(min_length=1, max_length=255)
mime_type: str
size_bytes: int = Field(gt=0, le=MAX_UPLOAD_PHOTO_SIZE_BYTES)
taken_at: datetime | None = None
day_number: int | None = None
visibility: str = "private"

_allowed_mime_types: ClassVar[dict[str, tuple[str, ...]]] = ALLOWED_IMAGE_MIME_TYPES

@field_validator("drive_file_id", "file_name", mode="before")
@classmethod
def _strip_required_text(cls, value: object) -> object:
if isinstance(value, str):
return value.strip()
return value

@field_validator("mime_type")
@classmethod
def _validate_mime_type(cls, value: str) -> str:
normalized_value = value.strip().lower()
if normalized_value not in cls._allowed_mime_types:
raise ValueError("Unsupported image format")
return normalized_value

@field_validator("visibility")
@classmethod
def _validate_visibility(cls, value: str) -> str:
normalized_value = value.strip().lower()
if normalized_value not in {"private", "public"}:
raise ValueError("visibility must be either 'private' or 'public'")
return normalized_value

@model_validator(mode="after")
def _validate_file_extension(self) -> "CreateUploadRequestPhotoRequest":
extension = PurePath(self.file_name).suffix.lower()
allowed_extensions = self._allowed_mime_types[self.mime_type]
if extension not in allowed_extensions:
raise ValueError("file_name extension does not match mime_type")
return self

def to_input(self) -> UploadPhotoInput:
return UploadPhotoInput(
drive_file_id=self.drive_file_id,
file_name=self.file_name,
mime_type=self.mime_type,
size_bytes=self.size_bytes,
taken_at=self.taken_at,
day_number=self.day_number,
visibility=self.visibility,
)


class CreateUploadRequestRequest(BaseModel):
event_id: UUID
photos: list[CreateUploadRequestPhotoRequest] = Field(
min_length=1,
max_length=MAX_UPLOAD_BATCH_SIZE,
)

def to_inputs(self) -> list[UploadPhotoInput]:
return [photo.to_input() for photo in self.photos]


class RejectUploadRequestRequest(BaseModel):
reason: str | None = None
36 changes: 36 additions & 0 deletions app/schema/response/staff/notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from datetime import datetime
from typing import Any
from uuid import UUID

from pydantic import BaseModel

from db.generated.models import StaffNotification


class StaffNotificationSchema(BaseModel):
id: UUID
type: str
payload: dict[str, Any]
read_at: datetime | None
created_at: datetime

@classmethod
def from_model(cls, notification: StaffNotification) -> "StaffNotificationSchema":
return cls(
id=notification.id,
type=notification.type,
payload=notification.payload,
read_at=notification.read_at,
created_at=notification.created_at,
)


class StaffNotificationListResponse(BaseModel):
items: list[StaffNotificationSchema]

@classmethod
def from_models(
cls,
notifications: list[StaffNotification],
) -> "StaffNotificationListResponse":
return cls(items=[StaffNotificationSchema.from_model(item) for item in notifications])
Loading