Merge dev-2026-03-22 into main #12

Merged
hzhang merged 15 commits from dev-2026-03-22 into main 2026-03-22 14:12:43 +00:00
3 changed files with 308 additions and 1 deletions
Showing only changes of commit 86911286c0 - Show all commits

289
app/api/routers/meetings.py Normal file
View File

@@ -0,0 +1,289 @@
"""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_id_or_code(db: Session, identifier: str) -> Meeting | None:
try:
mid = int(identifier)
meeting = db.query(Meeting).filter(Meeting.id == mid).first()
if meeting:
return meeting
except (ValueError, TypeError):
pass
return db.query(Meeting).filter(Meeting.meeting_code == str(identifier)).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 {
"id": meeting.id,
"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_id": meeting.project_id,
"project_code": project.project_code if project else None,
"milestone_id": meeting.milestone_id,
"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,
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)
if project:
project_id = _resolve_project_id(db, 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_id}")
def get_meeting(meeting_id: str, db: Session = Depends(get_db)):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
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_id}")
def update_meeting(
meeting_id: str,
body: MeetingUpdateBody,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
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_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_meeting(
meeting_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
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_id}/attend")
def attend_meeting(
meeting_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
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)

View File

@@ -39,6 +39,7 @@ from app.api.routers.milestones import router as milestones_router
from app.api.routers.roles import router as roles_router
from app.api.routers.proposes import router as proposes_router
from app.api.routers.milestone_actions import router as milestone_actions_router
from app.api.routers.meetings import router as meetings_router
app.include_router(auth_router)
app.include_router(tasks_router)
@@ -52,6 +53,7 @@ app.include_router(milestones_router)
app.include_router(roles_router)
app.include_router(proposes_router)
app.include_router(milestone_actions_router)
app.include_router(meetings_router)
# Auto schema migration for lightweight deployments

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
@@ -35,3 +35,19 @@ class Meeting(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
participants = relationship("MeetingParticipant", back_populates="meeting", cascade="all, delete-orphan")
class MeetingParticipant(Base):
__tablename__ = "meeting_participants"
__table_args__ = (
UniqueConstraint("meeting_id", "user_id", name="uq_meeting_participant"),
)
id = Column(Integer, primary_key=True, index=True)
meeting_id = Column(Integer, ForeignKey("meetings.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
joined_at = Column(DateTime(timezone=True), server_default=func.now())
meeting = relationship("Meeting", back_populates="participants")