From 214a9b109d7b7c150eef200467199c655e1fc237 Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 16 Mar 2026 13:22:14 +0000 Subject: [PATCH] refactor: replace issues backend with milestone tasks --- app/api/routers/comments.py | 51 +++-- app/api/routers/issues.py | 340 ------------------------------- app/api/routers/milestones.py | 175 ++++++---------- app/api/routers/misc.py | 373 ++++++++-------------------------- app/api/routers/monitor.py | 4 +- app/api/routers/projects.py | 15 +- app/api/routers/tasks.py | 347 +++++++++++++++++++++++++++++++ app/api/routers/users.py | 2 +- app/init_wizard.py | 10 +- app/main.py | 122 +++++++++-- app/models/activity.py | 4 +- app/models/models.py | 77 +------ app/models/notification.py | 4 +- app/models/task.py | 19 +- app/models/webhook.py | 8 +- app/models/worklog.py | 6 +- app/schemas/schemas.py | 86 ++++---- app/schemas/webhook.py | 2 +- app/services/monitoring.py | 24 +-- cli.py | 233 +++++++++++---------- 20 files changed, 836 insertions(+), 1066 deletions(-) delete mode 100644 app/api/routers/issues.py create mode 100644 app/api/routers/tasks.py diff --git a/app/api/routers/comments.py b/app/api/routers/comments.py index 59d71a5..39e7b05 100644 --- a/app/api/routers/comments.py +++ b/app/api/routers/comments.py @@ -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 diff --git a/app/api/routers/issues.py b/app/api/routers/issues.py deleted file mode 100644 index 14eafbb..0000000 --- a/app/api/routers/issues.py +++ /dev/null @@ -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} \ No newline at end of file diff --git a/app/api/routers/milestones.py b/app/api/routers/milestones.py index 44ead20..f839b29 100644 --- a/app/api/routers/milestones.py +++ b/app/api/routers/milestones.py @@ -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, diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py index d60f99a..959938c 100644 --- a/app/api/routers/misc.py +++ b/app/api/routers/misc.py @@ -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 diff --git a/app/api/routers/monitor.py b/app/api/routers/monitor.py index 1435e6f..b9d7be1 100644 --- a/app/api/routers/monitor.py +++ b/app/api/routers/monitor.py @@ -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(), diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index d790144..cbaadc1 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -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] diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py new file mode 100644 index 0000000..668c9f3 --- /dev/null +++ b/app/api/routers/tasks.py @@ -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, + } diff --git a/app/api/routers/users.py b/app/api/routers/users.py index e5efe10..ec0b39b 100644 --- a/app/api/routers/users.py +++ b/app/api/routers/users.py @@ -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 diff --git a/app/init_wizard.py b/app/init_wizard.py index 2e2e5fd..5268685 100644 --- a/app/init_wizard.py +++ b/app/init_wizard.py @@ -100,11 +100,11 @@ DEFAULT_PERMISSIONS = [ ("project.write", "Edit project", "project"), ("project.delete", "Delete project", "project"), ("project.manage_members", "Manage project members", "project"), - # Issue/Milestone permissions - ("issue.create", "Create issues", "issue"), - ("issue.read", "View issues", "issue"), - ("issue.write", "Edit issues", "issue"), - ("issue.delete", "Delete issues", "issue"), + # Task/Milestone permissions + ("task.create", "Create tasks", "task"), + ("task.read", "View tasks", "task"), + ("task.write", "Edit tasks", "task"), + ("task.delete", "Delete tasks", "task"), ("milestone.create", "Create milestones", "milestone"), ("milestone.read", "View milestones", "milestone"), ("milestone.write", "Edit milestones", "milestone"), diff --git a/app/main.py b/app/main.py index d31feee..270245d 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware app = FastAPI( title="HarborForge API", description="Agent/人类协同任务管理平台 API", - version="0.2.0" + version="0.3.0" ) # CORS @@ -24,11 +24,11 @@ def health_check(): @app.get("/version", tags=["System"]) def version(): - return {"name": "HarborForge", "version": "0.2.0", "description": "Agent/人类协同任务管理平台"} + return {"name": "HarborForge", "version": "0.3.0", "description": "Agent/人类协同任务管理平台"} # Register routers from app.api.routers.auth import router as auth_router -from app.api.routers.issues import router as issues_router +from app.api.routers.tasks import router as tasks_router from app.api.routers.projects import router as projects_router from app.api.routers.users import router as users_router from app.api.routers.comments import router as comments_router @@ -39,7 +39,7 @@ from app.api.routers.milestones import router as milestones_router from app.api.routers.roles import router as roles_router app.include_router(auth_router) -app.include_router(issues_router) +app.include_router(tasks_router) app.include_router(projects_router) app.include_router(users_router) app.include_router(comments_router) @@ -54,33 +54,113 @@ app.include_router(roles_router) def _migrate_schema(): from sqlalchemy import text from app.core.config import SessionLocal + + def _has_table(db, table_name: str) -> bool: + return db.execute(text("SHOW TABLES LIKE :table_name"), {"table_name": table_name}).fetchone() is not None + + def _has_column(db, table_name: str, column_name: str) -> bool: + return db.execute( + text(f"SHOW COLUMNS FROM {table_name} LIKE :column_name"), + {"column_name": column_name}, + ).fetchone() is not None + + def _drop_fk_constraints(db, table_name: str, referenced_table: str): + rows = db.execute(text( + """ + SELECT CONSTRAINT_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :table_name + AND REFERENCED_TABLE_NAME = :referenced_table + AND CONSTRAINT_NAME <> 'PRIMARY' + """ + ), {"table_name": table_name, "referenced_table": referenced_table}).fetchall() + for (constraint_name,) in rows: + db.execute(text(f"ALTER TABLE {table_name} DROP FOREIGN KEY `{constraint_name}`")) + + def _ensure_fk(db, table_name: str, column_name: str, referenced_table: str, referenced_column: str, constraint_name: str): + exists = db.execute(text( + """ + SELECT 1 + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :table_name + AND COLUMN_NAME = :column_name + AND REFERENCED_TABLE_NAME = :referenced_table + AND REFERENCED_COLUMN_NAME = :referenced_column + LIMIT 1 + """ + ), { + "table_name": table_name, + "column_name": column_name, + "referenced_table": referenced_table, + "referenced_column": referenced_column, + }).fetchone() + if not exists: + db.execute(text( + f"ALTER TABLE {table_name} ADD CONSTRAINT `{constraint_name}` FOREIGN KEY ({column_name}) REFERENCES {referenced_table}({referenced_column})" + )) + db = SessionLocal() try: - # issues.issue_subtype - result = db.execute(text("SHOW COLUMNS FROM issues LIKE 'issue_subtype'")).fetchone() - if not result: - db.execute(text("ALTER TABLE issues ADD COLUMN issue_subtype VARCHAR(64) NULL")) - # issues.issue_type enum -> varchar - result = db.execute(text("SHOW COLUMNS FROM issues WHERE Field='issue_type'")).fetchone() - if result and 'enum' in result[1].lower(): - db.execute(text("ALTER TABLE issues MODIFY issue_type VARCHAR(32) DEFAULT 'issue'")) # projects.project_code - result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'")).fetchone() - if not result: + result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'")) + if not result.fetchone(): db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL")) db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)")) + # projects.owner_name - result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")).fetchone() - if not result: + result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")) + if not result.fetchone(): db.execute(text("ALTER TABLE projects ADD COLUMN owner_name VARCHAR(128) NOT NULL DEFAULT ''")) + # projects.sub_projects / related_projects - result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'")).fetchone() - if not result: + result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'")) + if not result.fetchone(): db.execute(text("ALTER TABLE projects ADD COLUMN sub_projects VARCHAR(512) NULL")) - result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'")).fetchone() - if not result: + result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'")) + if not result.fetchone(): db.execute(text("ALTER TABLE projects ADD COLUMN related_projects VARCHAR(512) NULL")) + + # tasks extra fields + result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'task_type'")) + if not result.fetchone(): + db.execute(text("ALTER TABLE tasks ADD COLUMN task_type VARCHAR(32) DEFAULT 'task'")) + result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'task_subtype'")) + if not result.fetchone(): + db.execute(text("ALTER TABLE tasks ADD COLUMN task_subtype VARCHAR(64) NULL")) + result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'tags'")) + if not result.fetchone(): + db.execute(text("ALTER TABLE tasks ADD COLUMN tags VARCHAR(500) NULL")) + result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'resolution_summary'")) + if not result.fetchone(): + db.execute(text("ALTER TABLE tasks ADD COLUMN resolution_summary TEXT NULL")) + db.execute(text("ALTER TABLE tasks ADD COLUMN positions TEXT NULL")) + db.execute(text("ALTER TABLE tasks ADD COLUMN pending_matters TEXT NULL")) + + # comments: issue_id -> task_id + if _has_table(db, "comments"): + _drop_fk_constraints(db, "comments", "issues") + if _has_column(db, "comments", "issue_id") and not _has_column(db, "comments", "task_id"): + db.execute(text("ALTER TABLE comments CHANGE COLUMN issue_id task_id INTEGER NOT NULL")) + if _has_column(db, "comments", "task_id"): + _ensure_fk(db, "comments", "task_id", "tasks", "id", "fk_comments_task_id") + + # work_logs: issue_id -> task_id + if _has_table(db, "work_logs"): + _drop_fk_constraints(db, "work_logs", "issues") + if _has_column(db, "work_logs", "issue_id") and not _has_column(db, "work_logs", "task_id"): + db.execute(text("ALTER TABLE work_logs CHANGE COLUMN issue_id task_id INTEGER NOT NULL")) + if _has_column(db, "work_logs", "task_id"): + _ensure_fk(db, "work_logs", "task_id", "tasks", "id", "fk_work_logs_task_id") + + # Drop issues table if it exists (no longer used anywhere) + if _has_table(db, "issues"): + db.execute(text("DROP TABLE issues")) + + db.commit() except Exception as e: + db.rollback() print(f"Migration warning: {e}") finally: db.close() @@ -89,7 +169,7 @@ def _migrate_schema(): @app.on_event("startup") def startup(): from app.core.config import Base, engine, SessionLocal - from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission + from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting Base.metadata.create_all(bind=engine) _migrate_schema() diff --git a/app/models/activity.py b/app/models/activity.py index 5d7a011..1566662 100644 --- a/app/models/activity.py +++ b/app/models/activity.py @@ -7,8 +7,8 @@ class ActivityLog(Base): __tablename__ = "activity_logs" id = Column(Integer, primary_key=True, index=True) - action = Column(String(50), nullable=False) # e.g. "issue.created", "comment.added" - entity_type = Column(String(50), nullable=False) # "issue", "project", "comment" + action = Column(String(50), nullable=False) # e.g. "task.created", "comment.added" + entity_type = Column(String(50), nullable=False) # "task", "project", "comment" entity_id = Column(Integer, nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=True) details = Column(Text, nullable=True) # JSON string diff --git a/app/models/models.py b/app/models/models.py index 207f16d..7db8e07 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -6,96 +6,42 @@ from app.models.role_permission import Role import enum -class IssueType(str, enum.Enum): - MEETING = "meeting" - SUPPORT = "support" +class TaskType(str, enum.Enum): + """Task type enum — 'issue' is a subtype of task, not the other way around.""" ISSUE = "issue" MAINTENANCE = "maintenance" RESEARCH = "research" REVIEW = "review" STORY = "story" TEST = "test" - RESOLUTION = "resolution" # 决议案 - 用于 Agent 僵局提交 - TASK = "task" # legacy generic type + RESOLUTION = "resolution" + TASK = "task" -class IssueStatus(str, enum.Enum): +class TaskStatus(str, enum.Enum): OPEN = "open" - IN_PROGRESS = "in_progress" - RESOLVED = "resolved" + PENDING = "pending" + PROGRESSING = "progressing" CLOSED = "closed" - BLOCKED = "blocked" -class IssuePriority(str, enum.Enum): +class TaskPriority(str, enum.Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" CRITICAL = "critical" -class Issue(Base): - __tablename__ = "issues" - - id = Column(Integer, primary_key=True, index=True) - title = Column(String(255), nullable=False) - description = Column(Text, nullable=True) - issue_type = Column(String(32), default=IssueType.ISSUE.value) - issue_subtype = Column(String(64), nullable=True) - status = Column(Enum(IssueStatus), default=IssueStatus.OPEN) - priority = Column(Enum(IssuePriority), default=IssuePriority.MEDIUM) - - # Relationships - project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) - reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False) - assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True) - - # Resolution specific fields (for RESOLUTION type) - resolution_summary = Column(Text, nullable=True) # 僵局摘要 - positions = Column(Text, nullable=True) # 各方立场 (JSON) - pending_matters = Column(Text, nullable=True) # 待决事项 - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), onupdate=func.now()) - - # Tags (comma-separated for simplicity) - tags = Column(String(500), nullable=True) - - # Dependencies - depends_on_id = Column(Integer, ForeignKey("issues.id"), nullable=True) - - # Due date and milestone - due_date = Column(DateTime(timezone=True), nullable=True) - milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=True) - - # Task-specific fields - task_code = Column(String(64), nullable=True, unique=True, index=True) - depend_on = Column(Text, nullable=True) # JSON list of task codes - estimated_effort = Column(Integer, nullable=True) # 1-10 - estimated_working_time = Column(Time(timezone=True), nullable=True) - task_status = Column(String(32), default="open") # open, closed, pending, progressing - started_on = Column(DateTime(timezone=True), nullable=True) - finished_on = Column(DateTime(timezone=True), nullable=True) - related_tasks = Column(Text, nullable=True) # JSON list of task codes - created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) - - project = relationship("Project", back_populates="issues") - reporter = relationship("User", foreign_keys=[reporter_id], back_populates="reported_issues") - assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_issues") - comments = relationship("Comment", back_populates="issue", cascade="all, delete-orphan") - - class Comment(Base): __tablename__ = "comments" id = Column(Integer, primary_key=True, index=True) content = Column(Text, nullable=False) - issue_id = Column(Integer, ForeignKey("issues.id"), nullable=False) + task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False) author_id = Column(Integer, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) - issue = relationship("Issue", back_populates="comments") author = relationship("User", back_populates="comments") @@ -114,7 +60,6 @@ class Project(Base): owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) - issues = relationship("Issue", back_populates="project", cascade="all, delete-orphan") members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan") owner = relationship("User", back_populates="owned_projects") @@ -125,15 +70,13 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, nullable=False) email = Column(String(100), unique=True, nullable=False) - hashed_password = Column(String(255), nullable=True) # Nullable for OAuth users + hashed_password = Column(String(255), nullable=True) full_name = Column(String(100), nullable=True) is_active = Column(Boolean, default=True) is_admin = Column(Boolean, default=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) owned_projects = relationship("Project", back_populates="owner") - reported_issues = relationship("Issue", foreign_keys=[Issue.reporter_id], back_populates="reporter") - assigned_issues = relationship("Issue", foreign_keys=[Issue.assignee_id], back_populates="assignee") comments = relationship("Comment", back_populates="author") project_memberships = relationship("ProjectMember", back_populates="user") diff --git a/app/models/notification.py b/app/models/notification.py index e33ecad..b09be40 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -9,10 +9,10 @@ class Notification(Base): id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - type = Column(String(50), nullable=False) # issue.assigned, issue.mentioned, comment.added, milestone.due + type = Column(String(50), nullable=False) # task.assigned, task.mentioned, comment.added, milestone.due title = Column(String(255), nullable=False) message = Column(Text, nullable=True) - entity_type = Column(String(50), nullable=True) # issue, comment, milestone + entity_type = Column(String(50), nullable=True) # task, comment, milestone entity_id = Column(Integer, nullable=True) is_read = Column(Boolean, default=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/app/models/task.py b/app/models/task.py index e5964a0..03ed19a 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -26,18 +26,35 @@ class Task(Base): priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM) task_code = Column(String(64), nullable=True, unique=True, index=True) + # Task type/subtype (replaces old issue_type/issue_subtype) + task_type = Column(String(32), default="task") + task_subtype = Column(String(64), nullable=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=False) reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False) assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True) created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + # Tags (comma-separated) + tags = Column(String(500), nullable=True) + + # Dependencies depend_on = Column(Text, nullable=True) + related_tasks = Column(Text, nullable=True) + + # Effort tracking estimated_effort = Column(Integer, nullable=True) estimated_working_time = Column(Time, nullable=True) started_on = Column(DateTime(timezone=True), nullable=True) finished_on = Column(DateTime(timezone=True), nullable=True) - related_tasks = Column(Text, nullable=True) + + # Resolution specific fields (for task_type="resolution") + resolution_summary = Column(Text, nullable=True) + positions = Column(Text, nullable=True) + pending_matters = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + comments = relationship("Comment", foreign_keys="Comment.task_id", cascade="all, delete-orphan") diff --git a/app/models/webhook.py b/app/models/webhook.py index 57785a1..8df8fcc 100644 --- a/app/models/webhook.py +++ b/app/models/webhook.py @@ -5,10 +5,10 @@ import enum class WebhookEvent(str, enum.Enum): - ISSUE_CREATED = "issue.created" - ISSUE_UPDATED = "issue.updated" - ISSUE_CLOSED = "issue.closed" - ISSUE_DELETED = "issue.deleted" + TASK_CREATED = "task.created" + TASK_UPDATED = "task.updated" + TASK_CLOSED = "task.closed" + TASK_DELETED = "task.deleted" COMMENT_CREATED = "comment.created" RESOLUTION_CREATED = "resolution.created" MEMBER_ADDED = "member.added" diff --git a/app/models/worklog.py b/app/models/worklog.py index 53decf8..271b561 100644 --- a/app/models/worklog.py +++ b/app/models/worklog.py @@ -7,9 +7,9 @@ class WorkLog(Base): __tablename__ = "work_logs" id = Column(Integer, primary_key=True, index=True) - issue_id = Column(Integer, ForeignKey("issues.id"), nullable=False) + task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - hours = Column(Float, nullable=False) # Hours spent + hours = Column(Float, nullable=False) description = Column(Text, nullable=True) - logged_date = Column(DateTime(timezone=True), nullable=False) # When the work was done + logged_date = Column(DateTime(timezone=True), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index bad66cf..fa667fe 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -4,9 +4,7 @@ from datetime import datetime from enum import Enum -class IssueTypeEnum(str, Enum): - MEETING = "meeting" - SUPPORT = "support" +class TaskTypeEnum(str, Enum): ISSUE = "issue" MAINTENANCE = "maintenance" RESEARCH = "research" @@ -14,40 +12,39 @@ class IssueTypeEnum(str, Enum): STORY = "story" TEST = "test" RESOLUTION = "resolution" - TASK = "task" # legacy + TASK = "task" -class IssueStatusEnum(str, Enum): +class TaskStatusEnum(str, Enum): OPEN = "open" - IN_PROGRESS = "in_progress" - RESOLVED = "resolved" + PENDING = "pending" + PROGRESSING = "progressing" CLOSED = "closed" - BLOCKED = "blocked" -class IssuePriorityEnum(str, Enum): +class TaskPriorityEnum(str, Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" CRITICAL = "critical" -# Issue schemas -class IssueBase(BaseModel): +# Task schemas +class TaskBase(BaseModel): title: str description: Optional[str] = None - issue_type: IssueTypeEnum = IssueTypeEnum.ISSUE - issue_subtype: Optional[str] = None - priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM + task_type: TaskTypeEnum = TaskTypeEnum.TASK + task_subtype: Optional[str] = None + priority: TaskPriorityEnum = TaskPriorityEnum.MEDIUM tags: Optional[str] = None - depends_on_id: Optional[int] = None - due_date: Optional[datetime] = None + estimated_effort: Optional[int] = None + estimated_working_time: Optional[str] = None + + +class TaskCreate(TaskBase): + project_id: Optional[int] = None milestone_id: Optional[int] = None - - -class IssueCreate(IssueBase): - project_id: int - reporter_id: int + reporter_id: Optional[int] = None assignee_id: Optional[int] = None # Resolution specific resolution_summary: Optional[str] = None @@ -55,37 +52,35 @@ class IssueCreate(IssueBase): pending_matters: Optional[str] = None -class IssueUpdate(BaseModel): +class TaskUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None - issue_type: Optional[IssueTypeEnum] = None - issue_subtype: Optional[str] = None - status: Optional[IssueStatusEnum] = None - priority: Optional[IssuePriorityEnum] = None + task_type: Optional[TaskTypeEnum] = None + task_subtype: Optional[str] = None + status: Optional[TaskStatusEnum] = None + priority: Optional[TaskPriorityEnum] = None assignee_id: Optional[int] = None tags: Optional[str] = None - depends_on_id: Optional[int] = None - due_date: Optional[datetime] = None - milestone_id: Optional[int] = None + estimated_effort: Optional[int] = None # Resolution specific resolution_summary: Optional[str] = None positions: Optional[str] = None pending_matters: Optional[str] = None -class IssueResponse(IssueBase): +class TaskResponse(TaskBase): id: int - status: IssueStatusEnum + status: TaskStatusEnum + task_code: Optional[str] = None project_id: int + milestone_id: int reporter_id: int - assignee_id: Optional[int] - resolution_summary: Optional[str] - positions: Optional[str] - pending_matters: Optional[str] - due_date: Optional[datetime] = None - milestone_id: Optional[int] = None + assignee_id: Optional[int] = None + resolution_summary: Optional[str] = None + positions: Optional[str] = None + pending_matters: Optional[str] = None created_at: datetime - updated_at: Optional[datetime] + updated_at: Optional[datetime] = None class Config: from_attributes = True @@ -97,7 +92,7 @@ class CommentBase(BaseModel): class CommentCreate(CommentBase): - issue_id: int + task_id: int author_id: int @@ -107,10 +102,10 @@ class CommentUpdate(BaseModel): class CommentResponse(CommentBase): id: int - issue_id: int + task_id: int author_id: int created_at: datetime - updated_at: Optional[datetime] + updated_at: Optional[datetime] = None class Config: from_attributes = True @@ -147,15 +142,6 @@ class ProjectResponse(BaseModel): owner_id: int created_at: datetime -class _ProjectResponse_Inactive(ProjectBase): - id: int - owner_id: int - project_code: str | None = None - created_at: datetime - - class Config: - from_attributes = True - # User schemas class UserBase(BaseModel): diff --git a/app/schemas/webhook.py b/app/schemas/webhook.py index 9318cfe..dcbba12 100644 --- a/app/schemas/webhook.py +++ b/app/schemas/webhook.py @@ -6,7 +6,7 @@ from datetime import datetime class WebhookCreate(BaseModel): url: str secret: Optional[str] = None - events: str # comma-separated: "issue.created,issue.updated" + events: str # comma-separated: "task.created,task.updated" project_id: Optional[int] = None is_active: bool = True diff --git a/app/services/monitoring.py b/app/services/monitoring.py index df053fa..9de347b 100644 --- a/app/services/monitoring.py +++ b/app/services/monitoring.py @@ -5,7 +5,7 @@ from typing import Any, Dict, Tuple import requests from sqlalchemy.orm import Session -from app.models.models import Issue +from app.models.task import Task, TaskStatus from app.models.monitor import ProviderAccount, ProviderUsageSnapshot, MonitoredServer, ServerState _CACHE: Dict[str, Dict[str, Any]] = {} @@ -70,25 +70,25 @@ def _normalize_usage_payload(payload: Dict[str, Any]) -> Dict[str, Any]: } -def get_issue_stats_cached(db: Session, ttl_seconds: int = 1800): - key = 'issue_stats_24h' +def get_task_stats_cached(db: Session, ttl_seconds: int = 1800): + key = 'task_stats_24h' now = _now() hit = _CACHE.get(key) if hit and (now - hit['at']).total_seconds() < ttl_seconds: return hit['data'] since = now - timedelta(hours=24) - total = db.query(Issue).count() - new_24h = db.query(Issue).filter(Issue.created_at >= since).count() - processed_24h = db.query(Issue).filter( - Issue.updated_at != None, - Issue.updated_at >= since, - Issue.status.in_(['resolved', 'closed']) + total = db.query(Task).count() + new_24h = db.query(Task).filter(Task.created_at >= since).count() + processed_24h = db.query(Task).filter( + Task.updated_at != None, + Task.updated_at >= since, + Task.status == TaskStatus.CLOSED, ).count() data = { - 'total_issues': total, - 'new_issues_24h': new_24h, - 'processed_issues_24h': processed_24h, + 'total_tasks': total, + 'new_tasks_24h': new_24h, + 'processed_tasks_24h': processed_24h, 'computed_at': now.isoformat(), 'cache_ttl_seconds': ttl_seconds, } diff --git a/cli.py b/cli.py index e4f134c..bbbe65e 100755 --- a/cli.py +++ b/cli.py @@ -5,27 +5,47 @@ import argparse import json import os import sys -import urllib.request import urllib.error +import urllib.parse +import urllib.request BASE_URL = os.environ.get("HARBORFORGE_URL", "http://localhost:8000") TOKEN = os.environ.get("HARBORFORGE_TOKEN", "") +STATUS_ICON = { + "open": "🟢", + "pending": "🟡", + "progressing": "🔵", + "closed": "⚫", +} +TYPE_ICON = { + "resolution": "⚖️", + "task": "📋", + "story": "📖", + "test": "🧪", + "issue": "📌", + "maintenance": "🛠️", + "research": "🔬", + "review": "🧐", +} + + def _request(method, path, data=None): url = f"{BASE_URL}{path}" headers = {"Content-Type": "application/json"} if TOKEN: headers["Authorization"] = f"Bearer {TOKEN}" - body = json.dumps(data).encode() if data else None + body = json.dumps(data).encode() if data is not None else None req = urllib.request.Request(url, data=body, headers=headers, method=method) try: with urllib.request.urlopen(req) as resp: if resp.status == 204: return None - return json.loads(resp.read()) + raw = resp.read() + return json.loads(raw) if raw else None except urllib.error.HTTPError as e: print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr) sys.exit(1) @@ -45,36 +65,39 @@ def cmd_login(args): sys.exit(1) -def cmd_issues(args): +def cmd_tasks(args): params = [] if args.project: params.append(f"project_id={args.project}") if args.type: - params.append(f"issue_type={args.type}") + params.append(f"task_type={args.type}") if args.status: - params.append(f"issue_status={args.status}") + params.append(f"task_status={args.status}") qs = f"?{'&'.join(params)}" if params else "" - issues = _request("GET", f"/issues{qs}") - for i in issues: - status_icon = {"open": "🟢", "in_progress": "🔵", "resolved": "✅", "closed": "⚫", "blocked": "🔴"}.get(i["status"], "❓") - type_icon = {"resolution": "⚖️", "task": "📋", "story": "📖", "test": "🧪"}.get(i["issue_type"], "📌") - print(f" {status_icon} {type_icon} #{i['id']} [{i['priority']}] {i['title']}") + result = _request("GET", f"/tasks{qs}") + items = result.get("items", result if isinstance(result, list) else []) + for task in items: + status_icon = STATUS_ICON.get(task["status"], "❓") + type_icon = TYPE_ICON.get(task.get("task_type"), "📌") + print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}") -def cmd_issue_create(args): +def cmd_task_create(args): data = { "title": args.title, "project_id": args.project, + "milestone_id": args.milestone, "reporter_id": args.reporter, - "issue_type": args.type, + "task_type": args.type, "priority": args.priority or "medium", } if args.description: data["description"] = args.description if args.assignee: data["assignee_id"] = args.assignee + if args.subtype: + data["task_subtype"] = args.subtype - # Resolution specific if args.type == "resolution": if args.summary: data["resolution_summary"] = args.summary @@ -83,21 +106,21 @@ def cmd_issue_create(args): if args.pending: data["pending_matters"] = args.pending - result = _request("POST", "/issues", data) - print(f"Created issue #{result['id']}: {result['title']}") + result = _request("POST", "/tasks", data) + print(f"Created task #{result['id']}: {result['title']}") def cmd_projects(args): projects = _request("GET", "/projects") - for p in projects: - print(f" #{p['id']} {p['name']} - {p.get('description', '')}") + for project in projects: + print(f" #{project['id']} {project['name']} - {project.get('description', '')}") def cmd_users(args): users = _request("GET", "/users") - for u in users: - role = "👑" if u["is_admin"] else "👤" - print(f" {role} #{u['id']} {u['username']} ({u.get('full_name', '')})") + for user in users: + role = "👑" if user["is_admin"] else "👤" + print(f" {role} #{user['id']} {user['username']} ({user.get('full_name', '')})") def cmd_version(args): @@ -110,41 +133,38 @@ def cmd_health(args): print(f"Status: {result['status']}") - def cmd_search(args): - params = [f"q={args.query}"] + params = [f"q={urllib.parse.quote(args.query)}"] if args.project: params.append(f"project_id={args.project}") - qs = "&".join(params) - issues = _request("GET", f"/search/issues?{qs}") - if not issues: + result = _request("GET", f"/search/tasks?{'&'.join(params)}") + items = result.get("items", result if isinstance(result, list) else []) + if not items: print(" No results found.") return - for i in issues: - status_icon = {"open": "\U0001f7e2", "in_progress": "\U0001f535", "resolved": "\u2705", "closed": "\u26ab", "blocked": "\U0001f534"}.get(i["status"], "\u2753") - type_icon = {"resolution": "\u2696\ufe0f", "task": "\U0001f4cb", "story": "\U0001f4d6", "test": "\U0001f9ea"}.get(i["issue_type"], "\U0001f4cc") - print(f" {status_icon} {type_icon} #{i['id']} [{i['priority']}] {i['title']}") + for task in items: + status_icon = STATUS_ICON.get(task["status"], "❓") + type_icon = TYPE_ICON.get(task.get("task_type"), "📌") + print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}") def cmd_transition(args): - result = _request("POST", f"/issues/{args.issue_id}/transition?new_status={args.status}") - print(f"Issue #{result['id']} transitioned to: {result['status']}") + result = _request("POST", f"/tasks/{args.task_id}/transition?new_status={args.status}") + print(f"Task #{result['id']} transitioned to: {result['status']}") def cmd_stats(args): params = f"?project_id={args.project}" if args.project else "" stats = _request("GET", f"/dashboard/stats{params}") - print(f"Total: {stats['total']}") + print(f"Total: {stats['total_tasks']}") print("By status:") - for s, c in stats["by_status"].items(): - if c > 0: - print(f" {s}: {c}") + for status_name, count in stats["by_status"].items(): + if count > 0: + print(f" {status_name}: {count}") print("By type:") - for t, c in stats["by_type"].items(): - if c > 0: - print(f" {t}: {c}") - - + for task_type, count in stats["by_type"].items(): + if count > 0: + print(f" {task_type}: {count}") def cmd_milestones(args): @@ -153,10 +173,10 @@ def cmd_milestones(args): if not milestones: print(" No milestones found.") return - for m in milestones: - status_icon = "🟢" if m["status"] == "open" else "⚫" - due = f" (due: {m['due_date'][:10]})" if m.get("due_date") else "" - print(f" {status_icon} #{m['id']} {m['title']}{due}") + for milestone in milestones: + status_icon = STATUS_ICON.get(milestone["status"], "⚪") + due = f" (due: {milestone['due_date'][:10]})" if milestone.get("due_date") else "" + print(f" {status_icon} #{milestone['id']} {milestone['title']}{due}") def cmd_milestone_progress(args): @@ -165,140 +185,114 @@ def cmd_milestone_progress(args): filled = int(bar_len * result["progress_pct"] / 100) bar = "█" * filled + "░" * (bar_len - filled) print(f" {result['title']}") - print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_issues']})") + print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_tasks']})") def cmd_notifications(args): - params = [f"user_id={args.user}"] + params = [] if args.unread: params.append("unread_only=true") - qs = "&".join(params) - notifs = _request("GET", f"/notifications?{qs}") - if not notifs: + qs = f"?{'&'.join(params)}" if params else "" + notifications = _request("GET", f"/notifications{qs}") + if not notifications: print(" No notifications.") return - for n in notifs: - icon = "🔴" if not n["is_read"] else "⚪" - print(f" {icon} [{n['type']}] {n['title']}") + for notification in notifications: + icon = "🔴" if not notification["is_read"] else "⚪" + print(f" {icon} [{notification['type']}] {notification.get('message') or notification['title']}") def cmd_overdue(args): - params = f"?project_id={args.project}" if args.project else "" - issues = _request("GET", f"/issues/overdue{params}") - if not issues: - print(" No overdue issues! 🎉") - return - for i in issues: - due = i.get("due_date", "?")[:10] if i.get("due_date") else "?" - print(f" ⏰ #{i['id']} [{i['priority']}] {i['title']} (due: {due})") - - + print("Overdue tasks are not supported by the current milestone-based task schema.") def cmd_log_time(args): from datetime import datetime + data = { - 'issue_id': args.issue_id, - 'user_id': args.user_id, - 'hours': args.hours, - 'logged_date': datetime.utcnow().isoformat(), + "task_id": args.task_id, + "user_id": args.user_id, + "hours": args.hours, + "logged_date": datetime.utcnow().isoformat(), } if args.desc: - data['description'] = args.desc - r = api('POST', '/worklogs', json=data) - print(f'Logged {r["hours"]}h on issue #{r["issue_id"]} (log #{r["id"]})') + data["description"] = args.desc + result = _request("POST", "/worklogs", data) + print(f"Logged {result['hours']}h on task #{result['task_id']} (log #{result['id']})") def cmd_worklogs(args): - logs = api('GET', f'/issues/{args.issue_id}/worklogs') - for l in logs: - desc = f' - {l["description"]}' if l.get('description') else '' - print(f' [{l["id"]}] {l["hours"]}h by user#{l["user_id"]} on {l["logged_date"]}{desc}') - summary = api('GET', f'/issues/{args.issue_id}/worklogs/summary') - print(f' Total: {summary["total_hours"]}h ({summary["log_count"]} logs)') + logs = _request("GET", f"/tasks/{args.task_id}/worklogs") + for log in logs: + desc = f" - {log['description']}" if log.get("description") else "" + print(f" [{log['id']}] {log['hours']}h by user#{log['user_id']} on {log['logged_date']}{desc}") + summary = _request("GET", f"/tasks/{args.task_id}/worklogs/summary") + print(f" Total: {summary['total_hours']}h ({summary['log_count']} logs)") + def main(): parser = argparse.ArgumentParser(description="HarborForge CLI") sub = parser.add_subparsers(dest="command") - # login p_login = sub.add_parser("login", help="Login and get token") p_login.add_argument("username") p_login.add_argument("password") - # issues - p_issues = sub.add_parser("issues", help="List issues") - p_issues.add_argument("--project", "-p", type=int) - p_issues.add_argument("--type", "-t", choices=["task", "story", "test", "resolution"]) - p_issues.add_argument("--status", "-s") + p_tasks = sub.add_parser("tasks", aliases=["issues"], help="List tasks") + p_tasks.add_argument("--project", "-p", type=int) + p_tasks.add_argument("--type", "-t", choices=["task", "story", "test", "resolution", "issue", "maintenance", "research", "review"]) + p_tasks.add_argument("--status", "-s", choices=["open", "pending", "progressing", "closed"]) - # issue create - p_create = sub.add_parser("create-issue", help="Create an issue") + p_create = sub.add_parser("create-task", aliases=["create-issue"], help="Create a task") p_create.add_argument("title") p_create.add_argument("--project", "-p", type=int, required=True) + p_create.add_argument("--milestone", "-m", type=int, required=True) p_create.add_argument("--reporter", "-r", type=int, required=True) - p_create.add_argument("--type", "-t", default="task", choices=["task", "story", "test", "resolution"]) + p_create.add_argument("--type", "-t", default="task", choices=["task", "story", "test", "resolution", "issue", "maintenance", "research", "review"]) + p_create.add_argument("--subtype") p_create.add_argument("--priority", choices=["low", "medium", "high", "critical"]) p_create.add_argument("--description", "-d") p_create.add_argument("--assignee", "-a", type=int) - # Resolution fields p_create.add_argument("--summary") p_create.add_argument("--positions") p_create.add_argument("--pending") - # projects sub.add_parser("projects", help="List projects") - - # users sub.add_parser("users", help="List users") - - # version sub.add_parser("version", help="Show version") - - # health sub.add_parser("health", help="Health check") - - # search - p_search = sub.add_parser("search", help="Search issues") + p_search = sub.add_parser("search", help="Search tasks") p_search.add_argument("query") p_search.add_argument("--project", "-p", type=int) - # transition - p_trans = sub.add_parser("transition", help="Transition issue status") - p_trans.add_argument("issue_id", type=int) - p_trans.add_argument("status", choices=["open", "in_progress", "resolved", "closed", "blocked"]) + p_trans = sub.add_parser("transition", help="Transition task status") + p_trans.add_argument("task_id", type=int) + p_trans.add_argument("status", choices=["open", "pending", "progressing", "closed"]) - # stats p_stats = sub.add_parser("stats", help="Dashboard stats") p_stats.add_argument("--project", "-p", type=int) - - # milestones p_ms = sub.add_parser("milestones", help="List milestones") p_ms.add_argument("--project", "-p", type=int) - # milestone progress p_msp = sub.add_parser("milestone-progress", help="Show milestone progress") p_msp.add_argument("milestone_id", type=int) - # notifications - p_notif = sub.add_parser("notifications", help="List notifications") - p_notif.add_argument("--user", "-u", type=int, required=True) + p_notif = sub.add_parser("notifications", help="List notifications for current token user") p_notif.add_argument("--unread", action="store_true") - # overdue - p_overdue = sub.add_parser("overdue", help="List overdue issues") + p_overdue = sub.add_parser("overdue", help="Explain overdue-task support status") p_overdue.add_argument("--project", "-p", type=int) - p_logtime = sub.add_parser('log-time', help='Log time on an issue') - p_logtime.add_argument('issue_id', type=int) - p_logtime.add_argument('user_id', type=int) - p_logtime.add_argument('hours', type=float) - p_logtime.add_argument('--desc', '-d', type=str) + p_logtime = sub.add_parser("log-time", help="Log time on a task") + p_logtime.add_argument("task_id", type=int) + p_logtime.add_argument("user_id", type=int) + p_logtime.add_argument("hours", type=float) + p_logtime.add_argument("--desc", "-d", type=str) - p_worklogs = sub.add_parser('worklogs', help='List work logs for an issue') - p_worklogs.add_argument('issue_id', type=int) + p_worklogs = sub.add_parser("worklogs", help="List work logs for a task") + p_worklogs.add_argument("task_id", type=int) args = parser.parse_args() if not args.command: @@ -307,8 +301,10 @@ def main(): cmds = { "login": cmd_login, - "issues": cmd_issues, - "create-issue": cmd_issue_create, + "tasks": cmd_tasks, + "issues": cmd_tasks, + "create-task": cmd_task_create, + "create-issue": cmd_task_create, "projects": cmd_projects, "users": cmd_users, "version": cmd_version, @@ -327,5 +323,4 @@ def main(): if __name__ == "__main__": - import urllib.parse main()