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