refactor: split monolithic main.py into FastAPI routers (v0.2.0)
- app/api/deps.py: shared auth dependencies - app/api/routers/auth.py: login, me - app/api/routers/issues.py: CRUD, transition, assign, relations, tags, batch, search - app/api/routers/projects.py: CRUD, members, worklog summary - app/api/routers/users.py: CRUD, worklogs - app/api/routers/comments.py: CRUD - app/api/routers/webhooks.py: CRUD, logs, retry - app/api/routers/misc.py: API keys, activity, milestones, notifications, worklogs, export, dashboard - main.py: 1165 lines → 51 lines - Version bump to 0.2.0
This commit is contained in:
320
app/api/routers/misc.py
Normal file
320
app/api/routers/misc.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""Miscellaneous routers: API keys, activity, milestones, notifications, worklogs, export, dashboard."""
|
||||
import csv
|
||||
import io
|
||||
import secrets
|
||||
import math
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func as sqlfunc
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.models import models
|
||||
from app.models.apikey import APIKey
|
||||
from app.models.activity import ActivityLog
|
||||
from app.models.milestone import Milestone as MilestoneModel
|
||||
from app.models.notification import Notification as NotificationModel
|
||||
from app.models.worklog import WorkLog
|
||||
from app.schemas import schemas
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ API Keys ============
|
||||
|
||||
class APIKeyCreate(BaseModel):
|
||||
name: str
|
||||
user_id: int
|
||||
|
||||
class APIKeyResponse(BaseModel):
|
||||
id: int
|
||||
key: str
|
||||
name: str
|
||||
user_id: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_used_at: datetime | None = None
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/api-keys", response_model=APIKeyResponse, status_code=status.HTTP_201_CREATED, tags=["API Keys"])
|
||||
def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)):
|
||||
user = db.query(models.User).filter(models.User.id == data.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
key = secrets.token_hex(32)
|
||||
db_key = APIKey(key=key, name=data.name, user_id=data.user_id)
|
||||
db.add(db_key)
|
||||
db.commit()
|
||||
db.refresh(db_key)
|
||||
return db_key
|
||||
|
||||
|
||||
@router.get("/api-keys", response_model=List[APIKeyResponse], tags=["API Keys"])
|
||||
def list_api_keys(user_id: int = None, db: Session = Depends(get_db)):
|
||||
query = db.query(APIKey)
|
||||
if user_id:
|
||||
query = query.filter(APIKey.user_id == user_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["API Keys"])
|
||||
def revoke_api_key(key_id: int, db: Session = Depends(get_db)):
|
||||
key_obj = db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||
if not key_obj:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
key_obj.is_active = False
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ============ Activity Log ============
|
||||
|
||||
class ActivityLogResponse(BaseModel):
|
||||
id: int
|
||||
action: str
|
||||
entity_type: str
|
||||
entity_id: int
|
||||
user_id: int | None
|
||||
details: str | None
|
||||
created_at: datetime
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/activity", response_model=List[ActivityLogResponse], tags=["Activity"])
|
||||
def list_activity(entity_type: str = None, entity_id: int = None, user_id: int = None,
|
||||
limit: int = 50, db: Session = Depends(get_db)):
|
||||
query = db.query(ActivityLog)
|
||||
if entity_type:
|
||||
query = query.filter(ActivityLog.entity_type == entity_type)
|
||||
if entity_id:
|
||||
query = query.filter(ActivityLog.entity_id == entity_id)
|
||||
if user_id:
|
||||
query = query.filter(ActivityLog.user_id == user_id)
|
||||
return query.order_by(ActivityLog.created_at.desc()).limit(limit).all()
|
||||
|
||||
|
||||
# ============ Milestones ============
|
||||
|
||||
@router.post("/milestones", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED, tags=["Milestones"])
|
||||
def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db)):
|
||||
db_ms = MilestoneModel(**ms.model_dump())
|
||||
db.add(db_ms)
|
||||
db.commit()
|
||||
db.refresh(db_ms)
|
||||
return db_ms
|
||||
|
||||
|
||||
@router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"])
|
||||
def list_milestones(project_id: int = None, status_filter: str = None, db: Session = Depends(get_db)):
|
||||
query = db.query(MilestoneModel)
|
||||
if project_id:
|
||||
query = query.filter(MilestoneModel.project_id == project_id)
|
||||
if status_filter:
|
||||
query = query.filter(MilestoneModel.status == status_filter)
|
||||
return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all()
|
||||
|
||||
|
||||
@router.get("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
|
||||
def get_milestone(milestone_id: int, db: Session = Depends(get_db)):
|
||||
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
|
||||
if not ms:
|
||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||
return ms
|
||||
|
||||
|
||||
@router.patch("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
|
||||
def update_milestone(milestone_id: int, ms_update: schemas.MilestoneUpdate, db: Session = Depends(get_db)):
|
||||
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
|
||||
if not ms:
|
||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||
for field, value in ms_update.model_dump(exclude_unset=True).items():
|
||||
setattr(ms, field, value)
|
||||
db.commit()
|
||||
db.refresh(ms)
|
||||
return ms
|
||||
|
||||
|
||||
@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"])
|
||||
def delete_milestone(milestone_id: int, db: Session = Depends(get_db)):
|
||||
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
|
||||
if not ms:
|
||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||
db.delete(ms)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/milestones/{milestone_id}/issues", response_model=List[schemas.IssueResponse], tags=["Milestones"])
|
||||
def list_milestone_issues(milestone_id: int, db: Session = Depends(get_db)):
|
||||
return db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
|
||||
|
||||
|
||||
@router.get("/milestones/{milestone_id}/progress", tags=["Milestones"])
|
||||
def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
|
||||
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
|
||||
if not ms:
|
||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||
issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
|
||||
total = len(issues)
|
||||
done = sum(1 for i in issues if i.status in ("resolved", "closed"))
|
||||
return {"milestone_id": milestone_id, "title": ms.title, "total_issues": total,
|
||||
"completed": done, "progress_pct": round(done / total * 100, 1) if total else 0}
|
||||
|
||||
|
||||
# ============ Notifications ============
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
type: str
|
||||
title: str
|
||||
message: str | None = None
|
||||
entity_type: str | None = None
|
||||
entity_id: int | None = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=List[NotificationResponse], tags=["Notifications"])
|
||||
def list_notifications(user_id: int, unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db)):
|
||||
query = db.query(NotificationModel).filter(NotificationModel.user_id == user_id)
|
||||
if unread_only:
|
||||
query = query.filter(NotificationModel.is_read == False)
|
||||
return query.order_by(NotificationModel.created_at.desc()).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/notifications/count", tags=["Notifications"])
|
||||
def notification_count(user_id: int, db: Session = Depends(get_db)):
|
||||
count = db.query(NotificationModel).filter(
|
||||
NotificationModel.user_id == user_id, NotificationModel.is_read == False
|
||||
).count()
|
||||
return {"user_id": user_id, "unread": count}
|
||||
|
||||
|
||||
@router.post("/notifications/{notification_id}/read", tags=["Notifications"])
|
||||
def mark_read(notification_id: int, db: Session = Depends(get_db)):
|
||||
n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first()
|
||||
if not n:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
n.is_read = True
|
||||
db.commit()
|
||||
return {"status": "read"}
|
||||
|
||||
|
||||
@router.post("/notifications/read-all", tags=["Notifications"])
|
||||
def mark_all_read(user_id: int, db: Session = Depends(get_db)):
|
||||
db.query(NotificationModel).filter(
|
||||
NotificationModel.user_id == user_id, NotificationModel.is_read == False
|
||||
).update({"is_read": True})
|
||||
db.commit()
|
||||
return {"status": "all_read"}
|
||||
|
||||
|
||||
# ============ Work Logs ============
|
||||
|
||||
class WorkLogCreate(BaseModel):
|
||||
issue_id: int
|
||||
user_id: int
|
||||
hours: float
|
||||
description: str | None = None
|
||||
logged_date: datetime
|
||||
|
||||
class WorkLogResponse(BaseModel):
|
||||
id: int
|
||||
issue_id: int
|
||||
user_id: int
|
||||
hours: float
|
||||
description: str | None = None
|
||||
logged_date: datetime
|
||||
created_at: datetime
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"])
|
||||
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == wl.issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
user = db.query(models.User).filter(models.User.id == wl.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if wl.hours <= 0:
|
||||
raise HTTPException(status_code=400, detail="Hours must be positive")
|
||||
db_wl = WorkLog(**wl.model_dump())
|
||||
db.add(db_wl)
|
||||
db.commit()
|
||||
db.refresh(db_wl)
|
||||
return db_wl
|
||||
|
||||
|
||||
@router.get("/issues/{issue_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"])
|
||||
def list_issue_worklogs(issue_id: int, db: Session = Depends(get_db)):
|
||||
return db.query(WorkLog).filter(WorkLog.issue_id == issue_id).order_by(WorkLog.logged_date.desc()).all()
|
||||
|
||||
|
||||
@router.get("/issues/{issue_id}/worklogs/summary", tags=["Time Tracking"])
|
||||
def issue_worklog_summary(issue_id: int, db: Session = Depends(get_db)):
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.issue_id == issue_id).scalar() or 0
|
||||
count = db.query(WorkLog).filter(WorkLog.issue_id == issue_id).count()
|
||||
return {"issue_id": issue_id, "total_hours": round(total, 2), "log_count": count}
|
||||
|
||||
|
||||
@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"])
|
||||
def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
|
||||
wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first()
|
||||
if not wl:
|
||||
raise HTTPException(status_code=404, detail="Work log not found")
|
||||
db.delete(wl)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ============ Export ============
|
||||
|
||||
@router.get("/export/issues", tags=["Export"])
|
||||
def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)):
|
||||
query = db.query(models.Issue)
|
||||
if project_id:
|
||||
query = query.filter(models.Issue.project_id == project_id)
|
||||
issues = query.all()
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["id", "title", "type", "status", "priority", "project_id",
|
||||
"reporter_id", "assignee_id", "milestone_id", "due_date",
|
||||
"tags", "created_at", "updated_at"])
|
||||
for i in issues:
|
||||
writer.writerow([i.id, i.title, i.issue_type, i.status, i.priority, i.project_id,
|
||||
i.reporter_id, i.assignee_id, i.milestone_id, i.due_date,
|
||||
i.tags, i.created_at, i.updated_at])
|
||||
output.seek(0)
|
||||
return StreamingResponse(iter([output.getvalue()]), media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=issues.csv"})
|
||||
|
||||
|
||||
# ============ Dashboard ============
|
||||
|
||||
@router.get("/dashboard/stats", tags=["Dashboard"])
|
||||
def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)):
|
||||
query = db.query(models.Issue)
|
||||
if project_id:
|
||||
query = query.filter(models.Issue.project_id == project_id)
|
||||
total = query.count()
|
||||
by_status = {s: query.filter(models.Issue.status == s).count()
|
||||
for s in ["open", "in_progress", "resolved", "closed", "blocked"]}
|
||||
by_type = {t: query.filter(models.Issue.issue_type == t).count()
|
||||
for t in ["task", "story", "test", "resolution"]}
|
||||
by_priority = {p: query.filter(models.Issue.priority == p).count()
|
||||
for p in ["low", "medium", "high", "critical"]}
|
||||
return {"total": total, "by_status": by_status, "by_type": by_type, "by_priority": by_priority}
|
||||
Reference in New Issue
Block a user