refactor: replace issues backend with milestone tasks

This commit is contained in:
zhi
2026-03-16 13:22:14 +00:00
parent dc5d06489d
commit 214a9b109d
20 changed files with 836 additions and 1066 deletions

View File

@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from app.core.config import get_db
from app.models import models
from app.models.task import Task
from app.schemas import schemas
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
@@ -13,25 +14,24 @@ from app.models.notification import Notification as NotificationModel
router = APIRouter(tags=["Comments"])
def _notify_if_needed(db, issue_id, user_ids, ntype, title):
def _notify_if_needed(db, task_id, user_ids, ntype, title):
"""Helper to notify multiple users."""
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
return
for uid in set(user_ids):
if uid:
n = NotificationModel(user_id=uid, type=ntype, title=title, entity_type="issue", entity_id=issue_id)
n = NotificationModel(user_id=uid, type=ntype, title=title, entity_type="task", entity_id=task_id)
db.add(n)
db.commit()
@router.post("/comments", response_model=schemas.CommentResponse, status_code=status.HTTP_201_CREATED)
def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
# Get project_id from issue first
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
check_project_role(db, current_user.id, issue.project_id, min_role="viewer")
task = db.query(Task).filter(Task.id == comment.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="viewer")
db_comment = models.Comment(**comment.model_dump())
db.add(db_comment)
@@ -40,19 +40,19 @@ def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)
# Notify reporter and assignee (but not the commenter themselves)
notify_users = []
if issue.reporter_id != current_user.id:
notify_users.append(issue.reporter_id)
if issue.assignee_id and issue.assignee_id != current_user.id:
notify_users.append(issue.assignee_id)
if task.reporter_id != current_user.id:
notify_users.append(task.reporter_id)
if task.assignee_id and task.assignee_id != current_user.id:
notify_users.append(task.assignee_id)
if notify_users:
_notify_if_needed(db, issue.id, notify_users, "comment_added", f"New comment on: {issue.title[:50]}")
_notify_if_needed(db, task.id, notify_users, "comment_added", f"New comment on: {task.title[:50]}")
return db_comment
@router.get("/issues/{issue_id}/comments", response_model=List[schemas.CommentResponse])
def list_comments(issue_id: int, db: Session = Depends(get_db)):
return db.query(models.Comment).filter(models.Comment.issue_id == issue_id).all()
@router.get("/tasks/{task_id}/comments", response_model=List[schemas.CommentResponse])
def list_comments(task_id: int, db: Session = Depends(get_db)):
return db.query(models.Comment).filter(models.Comment.task_id == task_id).all()
@router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse)
@@ -60,10 +60,10 @@ def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: S
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
check_project_role(db, current_user.id, issue.project_id, min_role="viewer")
task = db.query(Task).filter(Task.id == comment.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="viewer")
for field, value in comment_update.model_dump(exclude_unset=True).items():
setattr(comment, field, value)
db.commit()
@@ -76,11 +76,10 @@ def delete_comment(comment_id: int, db: Session = Depends(get_db), current_user:
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
# Get issue to check project role
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
task = db.query(Task).filter(Task.id == comment.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="dev")
db.delete(comment)
db.commit()
return None

View File

@@ -1,340 +0,0 @@
"""Issues router."""
import math
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.config import get_db
from app.models import models
from app.schemas import schemas
from app.services.webhook import fire_webhooks_sync
from app.models.notification import Notification as NotificationModel
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
from app.services.activity import log_activity
router = APIRouter(tags=["Issues"])
# ---- Type / Subtype validation ----
ISSUE_SUBTYPE_MAP = {
'meeting': {'conference', 'handover', 'recap'},
'support': {'access', 'information'},
'issue': {'infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'},
'maintenance': {'deploy', 'release'},
'review': {'code_review', 'decision_review', 'function_review'},
'story': {'feature', 'improvement', 'refactor'},
'test': {'regression', 'security', 'smoke', 'stress'},
'research': set(),
'task': {'defect'},
'resolution': set(),
}
ALLOWED_ISSUE_TYPES = set(ISSUE_SUBTYPE_MAP.keys())
def _validate_issue_type_subtype(issue_type: str | None, issue_subtype: str | None, require_subtype: bool = False):
if issue_type is None:
raise HTTPException(status_code=400, detail='issue_type is required')
if issue_type not in ALLOWED_ISSUE_TYPES:
raise HTTPException(status_code=400, detail=f'Invalid issue_type: {issue_type}')
allowed = ISSUE_SUBTYPE_MAP.get(issue_type, set())
if issue_subtype:
if issue_subtype not in allowed:
raise HTTPException(status_code=400, detail=f'Invalid issue_subtype for {issue_type}: {issue_subtype}')
else:
if require_subtype and allowed:
raise HTTPException(status_code=400, detail=f'issue_subtype required for type: {issue_type}')
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message,
entity_type=entity_type, entity_id=entity_id)
db.add(n)
db.commit()
return n
# ---- CRUD ----
@router.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED)
def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
db_issue = models.Issue(**issue.model_dump())
db.add(db_issue)
db.commit()
db.refresh(db_issue)
event = "resolution.created" if db_issue.issue_type == "resolution" else "issue.created"
bg.add_task(fire_webhooks_sync, event,
{"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status},
db_issue.project_id, db)
log_activity(db, "issue.created", "issue", db_issue.id, current_user.id, {"title": db_issue.title})
return db_issue
@router.get("/issues")
def list_issues(
project_id: int = None, issue_status: str = None, issue_type: str = None, issue_subtype: str = None,
assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc",
page: int = 1, page_size: int = 50,
db: Session = Depends(get_db)
):
"""List issues with filtering, sorting, and pagination metadata."""
query = db.query(models.Issue)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
if issue_status:
query = query.filter(models.Issue.status == issue_status)
if issue_type:
query = query.filter(models.Issue.issue_type == issue_type)
if issue_subtype:
query = query.filter(models.Issue.issue_subtype == issue_subtype)
if assignee_id:
query = query.filter(models.Issue.assignee_id == assignee_id)
if tag:
query = query.filter(models.Issue.tags.contains(tag))
sort_fields = {
"created_at": models.Issue.created_at, "updated_at": models.Issue.updated_at,
"priority": models.Issue.priority, "title": models.Issue.title,
"due_date": models.Issue.due_date, "status": models.Issue.status,
}
sort_col = sort_fields.get(sort_by, models.Issue.created_at)
query = query.order_by(sort_col.asc() if sort_order == "asc" else 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": [schemas.IssueResponse.model_validate(i) for i in items],
"total": total, "page": page, "page_size": page_size, "total_pages": total_pages}
@router.get("/issues/overdue", response_model=List[schemas.IssueResponse])
def list_overdue_issues(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(models.Issue).filter(
models.Issue.due_date != None,
models.Issue.due_date < datetime.utcnow(),
models.Issue.status.notin_(["resolved", "closed"])
)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
return query.order_by(models.Issue.due_date.asc()).all()
@router.get("/issues/{issue_id}", response_model=schemas.IssueResponse)
def get_issue(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")
return issue
@router.patch("/issues/{issue_id}", response_model=schemas.IssueResponse)
def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if issue:
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
for field, value in update_data.items():
setattr(issue, field, value)
db.commit()
db.refresh(issue)
return issue
@router.delete("/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_issue(issue_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if issue:
check_project_role(db, current_user.id, issue.project_id, min_role="mgr")
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
log_activity(db, "issue.deleted", "issue", issue.id, current_user.id, {"title": issue.title})
db.delete(issue)
db.commit()
return None
# ---- Transition ----
@router.post("/issues/{issue_id}/transition", response_model=schemas.IssueResponse)
def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)):
valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"]
if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
old_status = issue.status
issue.status = new_status
db.commit()
db.refresh(issue)
event = "issue.closed" if new_status == "closed" else "issue.updated"
bg.add_task(fire_webhooks_sync, event,
{"issue_id": issue.id, "title": issue.title, "old_status": old_status, "new_status": new_status},
issue.project_id, db)
return issue
# ---- Assignment ----
@router.post("/issues/{issue_id}/assign")
def assign_issue(issue_id: int, assignee_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")
user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
issue.assignee_id = assignee_id
db.commit()
db.refresh(issue)
_notify_user(db, assignee_id, "issue.assigned",
f"Issue #{issue.id} assigned to you",
f"'{issue.title}' has been assigned to you.", "issue", issue.id)
return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title}
# ---- Relations ----
class IssueRelation(BaseModel):
parent_id: int
child_id: int
@router.post("/issues/link")
def link_issues(rel: IssueRelation, db: Session = Depends(get_db)):
parent = db.query(models.Issue).filter(models.Issue.id == rel.parent_id).first()
child = db.query(models.Issue).filter(models.Issue.id == rel.child_id).first()
if not parent or not child:
raise HTTPException(status_code=404, detail="Issue not found")
if rel.parent_id == rel.child_id:
raise HTTPException(status_code=400, detail="Cannot link issue to itself")
child.depends_on_id = rel.parent_id
db.commit()
return {"parent_id": rel.parent_id, "child_id": rel.child_id, "status": "linked"}
@router.delete("/issues/link")
def unlink_issues(child_id: int, db: Session = Depends(get_db)):
child = db.query(models.Issue).filter(models.Issue.id == child_id).first()
if not child:
raise HTTPException(status_code=404, detail="Issue not found")
child.depends_on_id = None
db.commit()
return {"child_id": child_id, "status": "unlinked"}
@router.get("/issues/{issue_id}/children", response_model=List[schemas.IssueResponse])
def get_children(issue_id: int, db: Session = Depends(get_db)):
return db.query(models.Issue).filter(models.Issue.depends_on_id == issue_id).all()
# ---- Tags ----
@router.post("/issues/{issue_id}/tags")
def add_tag(issue_id: int, tag: str, 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")
current = set(issue.tags.split(",")) if issue.tags else set()
current.add(tag.strip())
current.discard("")
issue.tags = ",".join(sorted(current))
db.commit()
return {"issue_id": issue_id, "tags": list(current)}
@router.delete("/issues/{issue_id}/tags")
def remove_tag(issue_id: int, tag: str, 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")
current = set(issue.tags.split(",")) if issue.tags else set()
current.discard(tag.strip())
current.discard("")
issue.tags = ",".join(sorted(current)) if current else None
db.commit()
return {"issue_id": issue_id, "tags": list(current)}
@router.get("/tags")
def list_all_tags(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(models.Issue.tags).filter(models.Issue.tags != None)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
all_tags = set()
for (tags,) in query.all():
for t in tags.split(","):
t = t.strip()
if t:
all_tags.add(t)
return {"tags": sorted(all_tags)}
# ---- Batch ----
class BatchTransition(BaseModel):
issue_ids: List[int]
new_status: str
class BatchAssign(BaseModel):
issue_ids: List[int]
assignee_id: int
@router.post("/issues/batch/transition")
def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = Depends(get_db)):
valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"]
if data.new_status not in valid_statuses:
raise HTTPException(status_code=400, detail="Invalid status")
updated = []
for issue_id in data.issue_ids:
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if issue:
old_status = issue.status
issue.status = data.new_status
updated.append({"id": issue.id, "title": issue.title, "old": old_status, "new": data.new_status})
db.commit()
for u in updated:
event = "issue.closed" if data.new_status == "closed" else "issue.updated"
bg.add_task(fire_webhooks_sync, event, u, None, db)
return {"updated": len(updated), "issues": updated}
@router.post("/issues/batch/assign")
def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
if not user:
raise HTTPException(status_code=404, detail="Assignee not found")
updated = []
for issue_id in data.issue_ids:
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if issue:
issue.assignee_id = data.assignee_id
updated.append(issue_id)
db.commit()
return {"updated": len(updated), "issue_ids": updated, "assignee_id": data.assignee_id}
# ---- Search ----
@router.get("/search/issues")
def search_issues(q: str, project_id: int = None, page: int = 1, page_size: int = 50,
db: Session = Depends(get_db)):
query = db.query(models.Issue).filter(
(models.Issue.title.contains(q)) | (models.Issue.description.contains(q))
)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
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": [schemas.IssueResponse.model_validate(i) for i in items],
"total": total, "page": page, "page_size": page_size, "total_pages": total_pages}

View File

@@ -1,15 +1,18 @@
"""Milestones API router."""
"""Milestones API router (project-scoped)."""
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from typing import List
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
from app.models import models
from app.models.milestone import Milestone
from app.models.task import Task, TaskStatus, TaskPriority
from app.models.support import Support
from app.models.meeting import Meeting
from app.schemas import schemas
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
@@ -17,7 +20,7 @@ router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones
def _serialize_milestone(milestone):
"""Serialize milestone with JSON fields."""
result = {
return {
"id": milestone.id,
"title": milestone.title,
"description": milestone.description,
@@ -30,12 +33,10 @@ def _serialize_milestone(milestone):
"created_at": milestone.created_at,
"updated_at": milestone.updated_at,
}
return result
@router.get("", response_model=List[schemas.MilestoneResponse])
def list_milestones(project_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""List all milestones for a project."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
return [_serialize_milestone(m) for m in milestones]
@@ -43,10 +44,8 @@ def list_milestones(project_id: int, db: Session = Depends(get_db), current_user
@router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED)
def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a new milestone for a project."""
check_project_role(db, current_user.id, project_id, min_role="mgr")
# Generate milestone_code: projCode:{i:05x}
project = db.query(models.Project).filter(models.Project.id == project_id).first()
project_code = project.project_code if project else f"P{project_id}"
max_ms = db.query(Milestone).filter(Milestone.project_id == project_id).order_by(Milestone.id.desc()).first()
@@ -54,9 +53,7 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se
milestone_code = f"{project_code}:{next_num:05x}"
data = milestone.model_dump()
# Remove project_id from data if present (it's already in the URL path)
data.pop('project_id', None)
# Handle JSON fields
if data.get("depend_on_milestones"):
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
if data.get("depend_on_tasks"):
@@ -70,7 +67,6 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse)
def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Get a milestone by ID."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
@@ -80,13 +76,11 @@ def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse)
def update_milestone(project_id: int, milestone_id: int, milestone: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Update a milestone."""
check_project_role(db, current_user.id, project_id, min_role="mgr")
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
data = milestone.model_dump(exclude_unset=True)
# Handle JSON fields
if "depend_on_milestones" in data:
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) if data["depend_on_milestones"] else None
if "depend_on_tasks" in data:
@@ -100,7 +94,6 @@ def update_milestone(project_id: int, milestone_id: int, milestone: schemas.Mile
@router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Delete a milestone."""
check_project_role(db, current_user.id, project_id, min_role="admin")
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone:
@@ -110,39 +103,8 @@ def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(g
return None
# Issue type helpers
ISSUE_TYPE_TASK = "task"
ISSUE_TYPE_SUPPORT = "support"
ISSUE_TYPE_MEETING = "meeting"
@router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED)
def create_milestone_task(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a task under a milestone."""
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
# Check if milestone is in progressing status - cannot add new story
if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
issue_data = issue.model_dump()
issue_data["issue_type"] = ISSUE_TYPE_TASK
issue_data["milestone_id"] = milestone_id
issue_data["project_id"] = project_id
issue_data["reporter_id"] = current_user.id
db_issue = models.Issue(**issue_data)
db.add(db_issue)
db.commit()
db.refresh(db_issue)
return db_issue
@router.post("/{milestone_id}/supports", status_code=status.HTTP_201_CREATED)
def create_milestone_support(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a support request under a milestone."""
@router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
@@ -151,98 +113,86 @@ def create_milestone_support(project_id: int, milestone_id: int, issue: schemas.
if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
issue_data = issue.model_dump()
issue_data["issue_type"] = ISSUE_TYPE_SUPPORT
issue_data["milestone_id"] = milestone_id
issue_data["project_id"] = project_id
issue_data["reporter_id"] = current_user.id
db_issue = models.Issue(**issue_data)
db.add(db_issue)
db.commit()
db.refresh(db_issue)
return db_issue
@router.post("/{milestone_id}/meetings", status_code=status.HTTP_201_CREATED)
def create_milestone_meeting(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a meeting under a milestone."""
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
# Generate task_code
milestone_code = milestone.milestone_code or f"m{milestone.id}"
max_task = db.query(Task).filter(Task.milestone_id == milestone.id).order_by(Task.id.desc()).first()
next_num = (max_task.id + 1) if max_task else 1
task_code = f"{milestone_code}:T{next_num:05x}"
if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
est_time = None
data = task_data.model_dump(exclude_unset=True)
if data.get("estimated_working_time"):
try:
est_time = datetime.strptime(data["estimated_working_time"], "%H:%M").time()
except:
pass
issue_data = issue.model_dump()
issue_data["issue_type"] = ISSUE_TYPE_MEETING
issue_data["milestone_id"] = milestone_id
issue_data["project_id"] = project_id
issue_data["reporter_id"] = current_user.id
db_issue = models.Issue(**issue_data)
db.add(db_issue)
task = Task(
title=data.get("title"),
description=data.get("description"),
task_type=data.get("task_type", "task"),
task_subtype=data.get("task_subtype"),
status=TaskStatus.OPEN,
priority=TaskPriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
task_code=task_code,
estimated_effort=data.get("estimated_effort"),
estimated_working_time=est_time,
created_by_id=current_user.id,
)
db.add(task)
db.commit()
db.refresh(db_issue)
return db_issue
db.refresh(task)
return task
@router.get("/{milestone_id}/items")
def get_milestone_items(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Get all items (tasks, supports, meetings) for a milestone."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
supports = db.query(Support).filter(Support.milestone_id == milestone_id).all()
meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).all()
tasks = []
supports = []
meetings = []
for issue in issues:
issue_data = {
"id": issue.id,
"title": issue.title,
"description": issue.description,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
"created_at": issue.created_at,
}
if issue.issue_type == ISSUE_TYPE_TASK:
tasks.append(issue_data)
elif issue.issue_type == ISSUE_TYPE_SUPPORT:
supports.append(issue_data)
elif issue.issue_type == ISSUE_TYPE_MEETING:
meetings.append(issue_data)
return {"tasks": tasks, "supports": supports, "meetings": meetings}
return {
"tasks": [{
"id": t.id, "title": t.title, "description": t.description,
"status": t.status.value if hasattr(t.status, 'value') else t.status,
"priority": t.priority.value if hasattr(t.priority, 'value') else t.priority,
"task_code": t.task_code, "created_at": t.created_at,
} for t in tasks],
"supports": [{
"id": s.id, "title": s.title, "description": s.description,
"status": s.status.value, "priority": s.priority.value, "created_at": s.created_at,
} for s in supports],
"meetings": [{
"id": m.id, "title": m.title, "description": m.description,
"status": m.status.value, "priority": m.priority.value, "created_at": m.created_at,
} for m in meetings],
}
@router.get("/{milestone_id}/progress")
def get_milestone_progress(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Get progress for a milestone - tasks only."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
# Count tasks only (not meetings or supports)
all_issues = db.query(models.Issue).filter(
models.Issue.milestone_id == milestone_id,
models.Issue.issue_type == ISSUE_TYPE_TASK
).all()
total = len(all_issues)
completed = sum(1 for i in all_issues if i.status and hasattr(i.status, 'value') and i.status.value == "closed")
all_tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
total = len(all_tasks)
completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED)
progress_pct = (completed / total * 100) if total > 0 else 0
# Calculate time progress if planned_release_date is set
time_progress = None
if milestone.planned_release_date:
if milestone.planned_release_date and milestone.created_at:
now = datetime.now()
if milestone.created_at and milestone.planned_release_date > milestone.created_at:
if milestone.planned_release_date > milestone.created_at:
total_duration = (milestone.planned_release_date - milestone.created_at).total_seconds()
elapsed = (now - milestone.created_at).total_seconds()
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
@@ -251,6 +201,7 @@ def get_milestone_progress(project_id: int, milestone_id: int, db: Session = Dep
"milestone_id": milestone_id,
"title": milestone.title,
"total": total,
"total_tasks": total,
"completed": completed,
"progress_pct": round(progress_pct, 1),
"time_progress_pct": round(time_progress, 1) if time_progress else None,

View File

@@ -103,22 +103,19 @@ def list_activity(entity_type: str = None, entity_id: int = None, user_id: int =
return query.order_by(ActivityLog.created_at.desc()).limit(limit).all()
# ============ Milestones ============
# ============ Milestones (top-level, non project-scoped) ============
@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), current_user: models.User = Depends(get_current_user_or_apikey)):
import json
# Generate milestone_code: projCode:{i:05x}
project = db.query(models.Project).filter(models.Project.id == ms.project_id).first()
project_code = project.project_code if project and project.project_code else f"P{ms.project_id}"
# Get max milestone number for this project
max_ms = db.query(MilestoneModel).filter(MilestoneModel.project_id == ms.project_id).order_by(MilestoneModel.id.desc()).first()
next_num = (max_ms.id + 1) if max_ms else 1
milestone_code = f"{project_code}:{next_num:05x}"
data = ms.model_dump()
# Serialize list fields to JSON strings
if data.get("depend_on_milestones"):
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
else:
@@ -176,188 +173,34 @@ def delete_milestone(milestone_id: int, db: Session = Depends(get_db)):
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)):
from datetime import datetime
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
# Count tasks only
issues = db.query(models.Issue).filter(
models.Issue.milestone_id == milestone_id,
models.Issue.issue_type == "task"
).all()
total = len(issues)
done = sum(1 for i in issues if i.status in ("resolved", "closed"))
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
total = len(tasks)
done = sum(1 for t in tasks if t.status == TaskStatus.CLOSED)
time_progress = None
if ms.planned_release_date and ms.created_at:
now = datetime.now()
total_duration = (ms.planned_release_date - ms.created_at).total_seconds()
elapsed = (now - ms.created_at).total_seconds()
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
return {"milestone_id": milestone_id, "title": ms.title, "total": total,
"completed": done, "progress_pct": round(done / total * 100, 1) if total else 0,
"time_progress_pct": round(time_progress, 1) if time_progress else None,
"planned_release_date": ms.planned_release_date}
@router.get("/milestones/{milestone_id}/items", tags=["Milestones"])
def milestone_items(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()
tasks = []
supports = []
meetings = []
for issue in issues:
issue_data = {
"id": issue.id,
"title": issue.title,
"description": issue.description,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
"created_at": issue.created_at,
}
if issue.issue_type == "task":
tasks.append(issue_data)
elif issue.issue_type == "support":
supports.append(issue_data)
elif issue.issue_type == "meeting":
meetings.append(issue_data)
return {"tasks": tasks, "supports": supports, "meetings": meetings}
@router.post("/milestones/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_task(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
import json
from datetime import datetime, time
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
# Check if milestone is progressing
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Get project_id from milestone
project_id = ms.project_id
# Generate task_code: i_{project_code}_{id:06x}
project = db.query(models.Project).filter(models.Project.id == project_id).first()
project_code = project.project_code if project else f"P{project_id}"
# Get max id for this project to generate unique code
max_issue = db.query(models.Issue).filter(models.Issue.project_id == project_id).order_by(models.Issue.id.desc()).first()
next_id = (max_issue.id + 1) if max_issue else 1
task_code = f"{milestone_code}:T{next_num:05x}"
# Parse estimated_working_time if provided
est_time = None
if issue_data.get("estimated_working_time"):
try:
est_time = datetime.strptime(issue_data["estimated_working_time"], "%H:%M").time()
except:
pass
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="task",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
# Task-specific fields
task_code=task_code,
estimated_effort=issue_data.get("estimated_effort"),
estimated_working_time=est_time,
task_status="open",
created_by_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
# Return with task_code
return {
"id": issue.id,
"title": issue.title,
"description": issue.description,
"task_code": issue.task_code,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
"created_at": issue.created_at,
"milestone_id": milestone_id,
"title": ms.title,
"total": total,
"total_tasks": total,
"completed": done,
"progress_pct": round(done / total * 100, 1) if total else 0,
"time_progress_pct": round(time_progress, 1) if time_progress else None,
"planned_release_date": ms.planned_release_date,
}
@router.post("/milestones/{milestone_id}/supports", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_support(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
project_id = ms.project_id
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="support",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
return issue
@router.post("/milestones/{milestone_id}/meetings", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_meeting(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
project_id = ms.project_id
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="meeting",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
return issue
# ============ Notifications ============
class NotificationResponse(BaseModel):
@@ -368,10 +211,24 @@ class NotificationResponse(BaseModel):
message: str | None = None
entity_type: str | None = None
entity_id: int | None = None
task_id: int | None = None
is_read: bool
created_at: datetime
class Config:
from_attributes = True
def _serialize_notification(notification: NotificationModel):
return {
"id": notification.id,
"user_id": notification.user_id,
"type": notification.type,
"title": notification.title,
"message": notification.message or notification.title,
"entity_type": notification.entity_type,
"entity_id": notification.entity_id,
"task_id": notification.entity_id if notification.entity_type == "task" else None,
"is_read": notification.is_read,
"created_at": notification.created_at,
}
@router.get("/notifications", response_model=List[NotificationResponse], tags=["Notifications"])
@@ -379,7 +236,8 @@ def list_notifications(unread_only: bool = False, limit: int = 50, db: Session =
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()
notifications = query.order_by(NotificationModel.created_at.desc()).limit(limit).all()
return [_serialize_notification(n) for n in notifications]
@router.get("/notifications/count", tags=["Notifications"])
@@ -414,7 +272,7 @@ def mark_all_read(db: Session = Depends(get_db), current_user: models.User = Dep
# ============ Work Logs ============
class WorkLogCreate(BaseModel):
issue_id: int
task_id: int
user_id: int
hours: float
description: str | None = None
@@ -422,7 +280,7 @@ class WorkLogCreate(BaseModel):
class WorkLogResponse(BaseModel):
id: int
issue_id: int
task_id: int
user_id: int
hours: float
description: str | None = None
@@ -434,9 +292,9 @@ class WorkLogResponse(BaseModel):
@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")
task = db.query(Task).filter(Task.id == wl.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task 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")
@@ -449,19 +307,19 @@ def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
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("/tasks/{task_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"])
def list_task_worklogs(task_id: int, db: Session = Depends(get_db)):
return db.query(WorkLog).filter(WorkLog.task_id == task_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.get("/tasks/{task_id}/worklogs/summary", tags=["Time Tracking"])
def task_worklog_summary(task_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == task_id).scalar() or 0
count = db.query(WorkLog).filter(WorkLog.task_id == task_id).count()
return {"task_id": task_id, "total_hours": round(total, 2), "log_count": count}
@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"])
@@ -476,47 +334,55 @@ def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
# ============ 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)
@router.get("/export/tasks", tags=["Export"])
def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(Task)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
issues = query.all()
query = query.filter(Task.project_id == project_id)
tasks = query.all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "title", "type", "subtype", "status", "priority", "project_id",
"reporter_id", "assignee_id", "milestone_id", "due_date",
"milestone_id", "reporter_id", "assignee_id", "task_code",
"tags", "created_at", "updated_at"])
for i in issues:
writer.writerow([i.id, i.title, i.issue_type, i.issue_subtype or "", 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])
for t in tasks:
writer.writerow([t.id, t.title, t.task_type, t.task_subtype or "",
t.status.value if hasattr(t.status, 'value') else t.status,
t.priority.value if hasattr(t.priority, 'value') else t.priority,
t.project_id, t.milestone_id, t.reporter_id, t.assignee_id, t.task_code,
t.tags, t.created_at, t.updated_at])
output.seek(0)
return StreamingResponse(iter([output.getvalue()]), media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=issues.csv"})
headers={"Content-Disposition": "attachment; filename=tasks.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)
query = db.query(Task)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
query = query.filter(Task.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}
by_status = {s.value: query.filter(Task.status == s).count() for s in TaskStatus}
by_type = {t: query.filter(Task.task_type == t).count()
for t in ["task", "story", "test", "resolution", "issue", "maintenance", "research", "review"]}
by_priority = {p.value: query.filter(Task.priority == p).count() for p in TaskPriority}
recent = query.order_by(Task.created_at.desc()).limit(10).all()
return {
"total": total,
"total_tasks": total,
"by_status": by_status,
"by_type": by_type,
"by_priority": by_priority,
"recent_tasks": [schemas.TaskResponse.model_validate(t) for t in recent],
}
# ============ Tasks ============
# ============ Milestone-scoped Tasks ============
@router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"])
def list_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
def list_milestone_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
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")
@@ -533,6 +399,8 @@ def list_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_d
"status": t.status.value if hasattr(t.status, "value") else t.status,
"priority": t.priority.value if hasattr(t.priority, "value") else t.priority,
"task_code": t.task_code,
"task_type": t.task_type,
"task_subtype": t.task_subtype,
"estimated_effort": t.estimated_effort,
"estimated_working_time": str(t.estimated_working_time) if t.estimated_working_time else None,
"started_on": t.started_on,
@@ -545,9 +413,7 @@ def list_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_d
@router.post("/tasks/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Tasks"])
def create_task(project_code: str, milestone_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
def create_milestone_task(project_code: str, milestone_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
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")
@@ -559,7 +425,6 @@ def create_task(project_code: str, milestone_id: int, task_data: dict, db: Sessi
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate task_code: milestoneCode:T{i:05x}
milestone_code = ms.milestone_code or f"m{ms.id}"
max_task = db.query(Task).filter(Task.milestone_id == ms.id).order_by(Task.id.desc()).first()
next_num = (max_task.id + 1) if max_task else 1
@@ -577,6 +442,8 @@ def create_task(project_code: str, milestone_id: int, task_data: dict, db: Sessi
description=task_data.get("description"),
status=TaskStatus.OPEN,
priority=TaskPriority.MEDIUM,
task_type=task_data.get("task_type", "task"),
task_subtype=task_data.get("task_subtype"),
project_id=project.id,
milestone_id=milestone_id,
reporter_id=current_user.id,
@@ -600,78 +467,6 @@ def create_task(project_code: str, milestone_id: int, task_data: dict, db: Sessi
}
@router.get("/tasks/{project_code}/{milestone_id}/{task_id}", tags=["Tasks"])
def get_task(project_code: str, milestone_id: int, task_id: int, db: Session = Depends(get_db)):
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")
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project.id,
Task.milestone_id == milestone_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return {
"id": task.id,
"title": task.title,
"description": task.description,
"status": task.status.value,
"priority": task.priority.value,
"task_code": task.task_code,
"estimated_effort": task.estimated_effort,
"estimated_working_time": str(task.estimated_working_time) if task.estimated_working_time else None,
"started_on": task.started_on,
"finished_on": task.finished_on,
"depend_on": task.depend_on,
"related_tasks": task.related_tasks,
"assignee_id": task.assignee_id,
"created_at": task.created_at,
}
@router.patch("/tasks/{project_code}/{milestone_id}/{task_id}", tags=["Tasks"])
def update_task(project_code: str, milestone_id: int, task_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
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")
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project.id,
Task.milestone_id == milestone_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if "title" in task_data:
task.title = task_data["title"]
if "description" in task_data:
task.description = task_data["description"]
if "status" in task_data:
new_status = task_data["status"]
if new_status == "progressing" and not task.started_on:
task.started_on = datetime.now()
if new_status == "closed" and not task.finished_on:
task.finished_on = datetime.now()
task.status = TaskStatus[new_status.upper()] if new_status.upper() in [s.name for s in TaskStatus] else TaskStatus.OPEN
if "priority" in task_data:
task.priority = TaskPriority[task_data["priority"].upper()] if task_data["priority"].upper() in [s.name for s in TaskPriority] else TaskPriority.MEDIUM
if "estimated_effort" in task_data:
task.estimated_effort = task_data["estimated_effort"]
if "assignee_id" in task_data:
task.assignee_id = task_data["assignee_id"]
db.commit()
db.refresh(task)
return task
# ============ Supports ============
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
@@ -709,7 +504,6 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate support_code: milestoneCode:S{i:05x}
milestone_code = ms.milestone_code or f"m{ms.id}"
max_support = db.query(Support).filter(Support.milestone_id == milestone_id).order_by(Support.id.desc()).first()
next_num = (max_support.id + 1) if max_support else 1
@@ -758,8 +552,6 @@ def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(ge
@router.post("/meetings/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Meetings"])
def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
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")
@@ -771,7 +563,6 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db:
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate meeting_code: milestoneCode:M{i:05x}
milestone_code = ms.milestone_code or f"m{ms.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

View File

@@ -18,7 +18,7 @@ from app.models.monitor import (
ServerHandshakeNonce,
)
from app.services.monitoring import (
get_issue_stats_cached,
get_task_stats_cached,
get_provider_usage_view,
get_server_states_view,
test_provider_connection,
@@ -66,7 +66,7 @@ def monitor_public_key():
@router.get('/public/overview')
def public_overview(db: Session = Depends(get_db)):
return {
'issues': get_issue_stats_cached(db, ttl_seconds=1800),
'tasks': get_task_stats_cached(db, ttl_seconds=1800),
'providers': get_provider_usage_view(db),
'servers': get_server_states_view(db, offline_after_minutes=7),
'generated_at': datetime.now(timezone.utc).isoformat(),

View File

@@ -231,14 +231,14 @@ def delete_project(
project_code = project.project_code
# Delete milestones and their issues
# Delete milestones and their tasks
from app.models.milestone import Milestone
from app.models.task import Task
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
for ms in milestones:
# Delete issues under milestone
issues = db.query(models.Issue).filter(models.Issue.milestone_id == ms.id).all()
for issue in issues:
db.delete(issue)
tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
for task in tasks:
db.delete(task)
db.delete(ms)
# Delete project members
@@ -363,13 +363,14 @@ from sqlalchemy import func as sqlfunc
@router.get("/{project_id}/worklogs/summary")
def project_worklog_summary(project_id: int, db: Session = Depends(get_db)):
from app.models.task import Task as TaskModel
results = db.query(
models.User.id, models.User.username,
sqlfunc.sum(WorkLog.hours).label("total_hours"),
sqlfunc.count(WorkLog.id).label("log_count")
).join(WorkLog, WorkLog.user_id == models.User.id)\
.join(models.Issue, WorkLog.issue_id == models.Issue.id)\
.filter(models.Issue.project_id == project_id)\
.join(TaskModel, WorkLog.task_id == TaskModel.id)\
.filter(TaskModel.project_id == project_id)\
.group_by(models.User.id, models.User.username).all()
total = sum(r.total_hours for r in results)
by_user = [{"user_id": r.id, "username": r.username, "hours": round(r.total_hours, 2), "logs": r.log_count} for r in results]

347
app/api/routers/tasks.py Normal file
View File

@@ -0,0 +1,347 @@
"""Tasks router — replaces the old issues router. All CRUD operates on the `tasks` table."""
import math
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.config import get_db
from app.models import models
from app.models.task import Task, TaskStatus, TaskPriority
from app.models.milestone import Milestone
from app.schemas import schemas
from app.services.webhook import fire_webhooks_sync
from app.models.notification import Notification as NotificationModel
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
from app.services.activity import log_activity
router = APIRouter(tags=["Tasks"])
# ---- Type / Subtype validation ----
TASK_SUBTYPE_MAP = {
'issue': {'infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'},
'maintenance': {'deploy', 'release'},
'review': {'code_review', 'decision_review', 'function_review'},
'story': {'feature', 'improvement', 'refactor'},
'test': {'regression', 'security', 'smoke', 'stress'},
'research': set(),
'task': {'defect'},
'resolution': set(),
}
ALLOWED_TASK_TYPES = set(TASK_SUBTYPE_MAP.keys())
def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None):
if task_type is None:
return
if task_type not in ALLOWED_TASK_TYPES:
raise HTTPException(status_code=400, detail=f'Invalid task_type: {task_type}')
allowed = TASK_SUBTYPE_MAP.get(task_type, set())
if task_subtype and task_subtype not in allowed:
raise HTTPException(status_code=400, detail=f'Invalid task_subtype for {task_type}: {task_subtype}')
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message,
entity_type=entity_type, entity_id=entity_id)
db.add(n)
db.commit()
return n
# ---- CRUD ----
@router.post("/tasks", response_model=schemas.TaskResponse, status_code=status.HTTP_201_CREATED)
def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
_validate_task_type_subtype(task_in.task_type, task_in.task_subtype)
data = task_in.model_dump(exclude_unset=True)
data["reporter_id"] = data.get("reporter_id") or current_user.id
data["created_by_id"] = current_user.id
if not data.get("project_id"):
raise HTTPException(status_code=400, detail="project_id is required")
if not data.get("milestone_id"):
raise HTTPException(status_code=400, detail="milestone_id is required")
check_project_role(db, current_user.id, data["project_id"], min_role="dev")
milestone = db.query(Milestone).filter(
Milestone.id == data["milestone_id"],
Milestone.project_id == data["project_id"],
).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
est_time = None
if data.get("estimated_working_time"):
try:
est_time = datetime.strptime(data["estimated_working_time"], "%H:%M").time()
except Exception:
pass
data["estimated_working_time"] = est_time
milestone_code = milestone.milestone_code or f"m{milestone.id}"
max_task = db.query(Task).filter(Task.milestone_id == milestone.id).order_by(Task.id.desc()).first()
next_num = (max_task.id + 1) if max_task else 1
data["task_code"] = f"{milestone_code}:T{next_num:05x}"
db_task = Task(**data)
db.add(db_task)
db.commit()
db.refresh(db_task)
event = "resolution.created" if db_task.task_type == "resolution" else "task.created"
bg.add_task(
fire_webhooks_sync,
event,
{"task_id": db_task.id, "title": db_task.title, "type": db_task.task_type, "status": db_task.status.value},
db_task.project_id,
db,
)
log_activity(db, "task.created", "task", db_task.id, current_user.id, {"title": db_task.title})
return db_task
@router.get("/tasks")
def list_tasks(
project_id: int = None, task_status: str = None, task_type: str = None, task_subtype: str = None,
assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc",
page: int = 1, page_size: int = 50,
db: Session = Depends(get_db)
):
query = db.query(Task)
if project_id:
query = query.filter(Task.project_id == project_id)
if task_status:
query = query.filter(Task.status == task_status)
if task_type:
query = query.filter(Task.task_type == task_type)
if task_subtype:
query = query.filter(Task.task_subtype == task_subtype)
if assignee_id:
query = query.filter(Task.assignee_id == assignee_id)
if tag:
query = query.filter(Task.tags.contains(tag))
sort_fields = {
"created_at": Task.created_at, "updated_at": Task.updated_at,
"priority": Task.priority, "title": Task.title,
}
sort_col = sort_fields.get(sort_by, Task.created_at)
query = query.order_by(sort_col.asc() if sort_order == "asc" else 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": [schemas.TaskResponse.model_validate(i) for i in items],
"total": total,
"total_tasks": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
@router.get("/tasks/{task_id}", response_model=schemas.TaskResponse)
def get_task(task_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.patch("/tasks/{task_id}", response_model=schemas.TaskResponse)
def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="dev")
update_data = task_update.model_dump(exclude_unset=True)
if "status" in update_data:
new_status = update_data["status"]
if new_status == "progressing" and not task.started_on:
task.started_on = datetime.utcnow()
if new_status == "closed" and not task.finished_on:
task.finished_on = datetime.utcnow()
for field, value in update_data.items():
setattr(task, field, value)
db.commit()
db.refresh(task)
return task
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="mgr")
log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title})
db.delete(task)
db.commit()
return None
# ---- Transition ----
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse)
def transition_task(task_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)):
valid_statuses = [s.value for s in TaskStatus]
if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
old_status = task.status.value if hasattr(task.status, 'value') else task.status
if new_status == "progressing" and not task.started_on:
task.started_on = datetime.utcnow()
if new_status == "closed" and not task.finished_on:
task.finished_on = datetime.utcnow()
task.status = new_status
db.commit()
db.refresh(task)
event = "task.closed" if new_status == "closed" else "task.updated"
bg.add_task(fire_webhooks_sync, event,
{"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status},
task.project_id, db)
return task
# ---- Assignment ----
@router.post("/tasks/{task_id}/assign")
def assign_task(task_id: int, assignee_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
task.assignee_id = assignee_id
db.commit()
db.refresh(task)
_notify_user(db, assignee_id, "task.assigned",
f"Task #{task.id} assigned to you",
f"'{task.title}' has been assigned to you.", "task", task.id)
return {"task_id": task.id, "assignee_id": assignee_id, "title": task.title}
# ---- Tags ----
@router.post("/tasks/{task_id}/tags")
def add_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
current = set(task.tags.split(",")) if task.tags else set()
current.add(tag.strip())
current.discard("")
task.tags = ",".join(sorted(current))
db.commit()
return {"task_id": task_id, "tags": list(current)}
@router.delete("/tasks/{task_id}/tags")
def remove_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
current = set(task.tags.split(",")) if task.tags else set()
current.discard(tag.strip())
current.discard("")
task.tags = ",".join(sorted(current)) if current else None
db.commit()
return {"task_id": task_id, "tags": list(current)}
@router.get("/tags")
def list_all_tags(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(Task.tags).filter(Task.tags != None)
if project_id:
query = query.filter(Task.project_id == project_id)
all_tags = set()
for (tags,) in query.all():
for t in tags.split(","):
t = t.strip()
if t:
all_tags.add(t)
return {"tags": sorted(all_tags)}
# ---- Batch ----
class BatchTransition(BaseModel):
task_ids: List[int]
new_status: str
class BatchAssign(BaseModel):
task_ids: List[int]
assignee_id: int
@router.post("/tasks/batch/transition")
def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = Depends(get_db)):
valid_statuses = [s.value for s in TaskStatus]
if data.new_status not in valid_statuses:
raise HTTPException(status_code=400, detail="Invalid status")
updated = []
for task_id in data.task_ids:
task = db.query(Task).filter(Task.id == task_id).first()
if task:
old_status = task.status.value if hasattr(task.status, 'value') else task.status
task.status = data.new_status
updated.append({"id": task.id, "title": task.title, "old": old_status, "new": data.new_status})
db.commit()
for u in updated:
event = "task.closed" if data.new_status == "closed" else "task.updated"
bg.add_task(fire_webhooks_sync, event, u, None, db)
return {"updated": len(updated), "tasks": updated}
@router.post("/tasks/batch/assign")
def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
if not user:
raise HTTPException(status_code=404, detail="Assignee not found")
updated = []
for task_id in data.task_ids:
task = db.query(Task).filter(Task.id == task_id).first()
if task:
task.assignee_id = data.assignee_id
updated.append(task_id)
db.commit()
return {"updated": len(updated), "task_ids": updated, "assignee_id": data.assignee_id}
# ---- Search ----
@router.get("/search/tasks")
def search_tasks(q: str, project_id: int = None, page: int = 1, page_size: int = 50,
db: Session = Depends(get_db)):
query = db.query(Task).filter(
(Task.title.contains(q)) | (Task.description.contains(q))
)
if project_id:
query = query.filter(Task.project_id == project_id)
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": [schemas.TaskResponse.model_validate(i) for i in items],
"total": total,
"total_tasks": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}

View File

@@ -65,7 +65,7 @@ from datetime import datetime
class WorkLogResponse(BaseModel):
id: int
issue_id: int
task_id: int
user_id: int
hours: float
description: str | None = None