"""Meetings router — code-first CRUD with participant/attend support.""" import math from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from pydantic import BaseModel from app.core.config import get_db from app.models import models from app.models.meeting import Meeting, MeetingStatus, MeetingPriority, MeetingParticipant from app.models.milestone import Milestone from app.api.deps import get_current_user_or_apikey from app.api.rbac import check_project_role router = APIRouter(tags=["Meetings"]) # ---- helpers ---- def _find_meeting_by_code(db: Session, meeting_code: str) -> Meeting | None: return db.query(Meeting).filter(Meeting.meeting_code == str(meeting_code)).first() def _resolve_project_id(db: Session, project_code: str | None) -> int | None: if not project_code: return None project = db.query(models.Project).filter(models.Project.project_code == project_code).first() if not project: raise HTTPException(status_code=404, detail="Project not found") return project.id def _resolve_milestone(db: Session, milestone_code: str | None, project_id: int | None) -> Milestone | None: if not milestone_code: return None query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code) if project_id: query = query.filter(Milestone.project_id == project_id) ms = query.first() if not ms: raise HTTPException(status_code=404, detail="Milestone not found") return ms def _get_participant_usernames(db: Session, meeting: Meeting) -> list[str]: parts = db.query(MeetingParticipant).filter(MeetingParticipant.meeting_id == meeting.id).all() usernames = [] for p in parts: user = db.query(models.User).filter(models.User.id == p.user_id).first() if user: usernames.append(user.username) return usernames def _serialize_meeting(db: Session, meeting: Meeting) -> dict: project = db.query(models.Project).filter(models.Project.id == meeting.project_id).first() milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first() return { "code": meeting.meeting_code, "meeting_code": meeting.meeting_code, "title": meeting.title, "description": meeting.description, "status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status, "priority": meeting.priority.value if hasattr(meeting.priority, "value") else meeting.priority, "project_code": project.project_code if project else None, "milestone_code": milestone.milestone_code if milestone else None, "reporter_id": meeting.reporter_id, "meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None, "scheduled_at": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None, "duration_minutes": meeting.duration_minutes, "participants": _get_participant_usernames(db, meeting), "created_at": meeting.created_at.isoformat() if meeting.created_at else None, "updated_at": meeting.updated_at.isoformat() if meeting.updated_at else None, } # ---- CRUD ---- class MeetingCreateBody(BaseModel): project_code: str title: str milestone_code: Optional[str] = None description: Optional[str] = None meeting_time: Optional[str] = None duration_minutes: Optional[int] = None @router.post("/meetings", status_code=status.HTTP_201_CREATED) def create_meeting( body: MeetingCreateBody, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): project_id = _resolve_project_id(db, body.project_code) if not project_id: raise HTTPException(status_code=400, detail="project_code is required") check_project_role(db, current_user.id, project_id, min_role="dev") milestone = _resolve_milestone(db, body.milestone_code, project_id) if not milestone: # If no milestone_code, try to use the first active milestone milestone = db.query(Milestone).filter( Milestone.project_id == project_id, ).order_by(Milestone.id.desc()).first() if not milestone: raise HTTPException(status_code=400, detail="No milestone available for project") milestone_code = milestone.milestone_code or f"m{milestone.id}" max_meeting = db.query(Meeting).filter(Meeting.milestone_id == milestone.id).order_by(Meeting.id.desc()).first() next_num = (max_meeting.id + 1) if max_meeting else 1 meeting_code = f"{milestone_code}:M{next_num:05x}" scheduled_at = None if body.meeting_time: try: scheduled_at = datetime.fromisoformat(body.meeting_time.replace("Z", "+00:00")) except Exception: raise HTTPException(status_code=400, detail="Invalid meeting_time format") meeting = Meeting( title=body.title, description=body.description, status=MeetingStatus.SCHEDULED, priority=MeetingPriority.MEDIUM, project_id=project_id, milestone_id=milestone.id, reporter_id=current_user.id, meeting_code=meeting_code, scheduled_at=scheduled_at, duration_minutes=body.duration_minutes, ) db.add(meeting) db.commit() db.refresh(meeting) # Auto-add creator as participant participant = MeetingParticipant(meeting_id=meeting.id, user_id=current_user.id) db.add(participant) db.commit() return _serialize_meeting(db, meeting) @router.get("/meetings") def list_meetings( project: str = None, project_code: str = None, status_value: str = Query(None, alias="status"), order_by: str = None, page: int = 1, page_size: int = 50, db: Session = Depends(get_db), ): query = db.query(Meeting) effective_project = project_code or project if effective_project: project_id = _resolve_project_id(db, effective_project) if project_id: query = query.filter(Meeting.project_id == project_id) if status_value: query = query.filter(Meeting.status == status_value) sort_fields = { "created": Meeting.created_at, "created_at": Meeting.created_at, "due-date": Meeting.scheduled_at, "scheduled_at": Meeting.scheduled_at, "name": Meeting.title, "title": Meeting.title, } sort_col = sort_fields.get(order_by, Meeting.created_at) query = query.order_by(sort_col.desc()) total = query.count() page = max(1, page) page_size = min(max(1, page_size), 200) total_pages = math.ceil(total / page_size) if total else 1 items = query.offset((page - 1) * page_size).limit(page_size).all() return { "items": [_serialize_meeting(db, m) for m in items], "total": total, "page": page, "page_size": page_size, "total_pages": total_pages, } @router.get("/meetings/{meeting_code}") def get_meeting(meeting_code: str, db: Session = Depends(get_db)): meeting = _find_meeting_by_code(db, meeting_code) if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") return _serialize_meeting(db, meeting) class MeetingUpdateBody(BaseModel): title: Optional[str] = None description: Optional[str] = None status: Optional[str] = None meeting_time: Optional[str] = None duration_minutes: Optional[int] = None @router.patch("/meetings/{meeting_code}") def update_meeting( meeting_code: str, body: MeetingUpdateBody, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): meeting = _find_meeting_by_code(db, meeting_code) if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") check_project_role(db, current_user.id, meeting.project_id, min_role="dev") update_data = body.model_dump(exclude_unset=True) if not update_data: raise HTTPException(status_code=400, detail="No supported fields to update") if "title" in update_data and update_data["title"] is not None: meeting.title = update_data["title"] if "description" in update_data: meeting.description = update_data["description"] if "status" in update_data and update_data["status"] is not None: meeting.status = MeetingStatus(update_data["status"]) if "meeting_time" in update_data and update_data["meeting_time"] is not None: try: meeting.scheduled_at = datetime.fromisoformat(update_data["meeting_time"].replace("Z", "+00:00")) except Exception: raise HTTPException(status_code=400, detail="Invalid meeting_time format") if "duration_minutes" in update_data: meeting.duration_minutes = update_data["duration_minutes"] db.commit() db.refresh(meeting) return _serialize_meeting(db, meeting) @router.delete("/meetings/{meeting_code}", status_code=status.HTTP_204_NO_CONTENT) def delete_meeting( meeting_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): meeting = _find_meeting_by_code(db, meeting_code) if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") check_project_role(db, current_user.id, meeting.project_id, min_role="dev") db.delete(meeting) db.commit() return None # ---- Attend ---- @router.post("/meetings/{meeting_code}/attend") def attend_meeting( meeting_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): meeting = _find_meeting_by_code(db, meeting_code) if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") check_project_role(db, current_user.id, meeting.project_id, min_role="viewer") existing = db.query(MeetingParticipant).filter( MeetingParticipant.meeting_id == meeting.id, MeetingParticipant.user_id == current_user.id, ).first() if existing: return _serialize_meeting(db, meeting) participant = MeetingParticipant(meeting_id=meeting.id, user_id=current_user.id) db.add(participant) db.commit() return _serialize_meeting(db, meeting)