324 lines
13 KiB
Python
324 lines
13 KiB
Python
"""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.api.deps import get_current_user_or_apikey
|
|
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(unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
|
query = db.query(NotificationModel).filter(NotificationModel.user_id == current_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(db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
|
count = db.query(NotificationModel).filter(
|
|
NotificationModel.user_id == current_user.id, NotificationModel.is_read == False
|
|
).count()
|
|
return {"user_id": current_user.id, "count": count, "unread": count}
|
|
|
|
|
|
@router.post("/notifications/{notification_id}/read", tags=["Notifications"])
|
|
def mark_read(notification_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
|
n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first()
|
|
if not n:
|
|
raise HTTPException(status_code=404, detail="Notification not found")
|
|
if n.user_id != current_user.id and not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
n.is_read = True
|
|
db.commit()
|
|
return {"status": "read"}
|
|
|
|
|
|
@router.post("/notifications/read-all", tags=["Notifications"])
|
|
def mark_all_read(db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
|
db.query(NotificationModel).filter(
|
|
NotificationModel.user_id == current_user.id, NotificationModel.is_read == False
|
|
).update({"is_read": True})
|
|
db.commit()
|
|
return {"status": "all_read", "user_id": current_user.id}
|
|
|
|
|
|
# ============ 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}
|