refactor: replace issues backend with milestone tasks

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

View File

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

View File

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

View File

@@ -1,15 +1,18 @@
"""Milestones API router.""" """Milestones API router (project-scoped)."""
import json import json
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List
from app.core.config import get_db from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role from app.api.rbac import check_project_role
from app.models import models from app.models import models
from app.models.milestone import Milestone 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 from app.schemas import schemas
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"]) 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): def _serialize_milestone(milestone):
"""Serialize milestone with JSON fields.""" """Serialize milestone with JSON fields."""
result = { return {
"id": milestone.id, "id": milestone.id,
"title": milestone.title, "title": milestone.title,
"description": milestone.description, "description": milestone.description,
@@ -30,12 +33,10 @@ def _serialize_milestone(milestone):
"created_at": milestone.created_at, "created_at": milestone.created_at,
"updated_at": milestone.updated_at, "updated_at": milestone.updated_at,
} }
return result
@router.get("", response_model=List[schemas.MilestoneResponse]) @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)): 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") check_project_role(db, current_user.id, project_id, min_role="viewer")
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all() milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
return [_serialize_milestone(m) for m in milestones] 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) @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)): 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") 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 = db.query(models.Project).filter(models.Project.id == project_id).first()
project_code = project.project_code if project else f"P{project_id}" 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() 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}" milestone_code = f"{project_code}:{next_num:05x}"
data = milestone.model_dump() data = milestone.model_dump()
# Remove project_id from data if present (it's already in the URL path)
data.pop('project_id', None) data.pop('project_id', None)
# Handle JSON fields
if data.get("depend_on_milestones"): if data.get("depend_on_milestones"):
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
if data.get("depend_on_tasks"): 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) @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)): 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") 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() milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone: 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) @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)): 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") 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() db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone: if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
data = milestone.model_dump(exclude_unset=True) data = milestone.model_dump(exclude_unset=True)
# Handle JSON fields
if "depend_on_milestones" in data: if "depend_on_milestones" in data:
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) if data["depend_on_milestones"] else None data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) if data["depend_on_milestones"] else None
if "depend_on_tasks" in data: 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) @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)): 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") 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() db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone: if not db_milestone:
@@ -110,39 +103,8 @@ def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(g
return None return None
# Issue type helpers @router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
ISSUE_TYPE_TASK = "task" 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)):
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."""
check_project_role(db, current_user.id, project_id, min_role="dev") 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() milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone: 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": 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") raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
issue_data = issue.model_dump() # Generate task_code
issue_data["issue_type"] = ISSUE_TYPE_SUPPORT milestone_code = milestone.milestone_code or f"m{milestone.id}"
issue_data["milestone_id"] = milestone_id max_task = db.query(Task).filter(Task.milestone_id == milestone.id).order_by(Task.id.desc()).first()
issue_data["project_id"] = project_id next_num = (max_task.id + 1) if max_task else 1
issue_data["reporter_id"] = current_user.id task_code = f"{milestone_code}:T{next_num:05x}"
db_issue = models.Issue(**issue_data)
db.add(db_issue) 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
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.commit()
db.refresh(db_issue) db.refresh(task)
return db_issue return task
@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")
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_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)
db.commit()
db.refresh(db_issue)
return db_issue
@router.get("/{milestone_id}/items") @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)): 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") 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() milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") 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 = [] return {
supports = [] "tasks": [{
meetings = [] "id": t.id, "title": t.title, "description": t.description,
"status": t.status.value if hasattr(t.status, 'value') else t.status,
for issue in issues: "priority": t.priority.value if hasattr(t.priority, 'value') else t.priority,
issue_data = { "task_code": t.task_code, "created_at": t.created_at,
"id": issue.id, } for t in tasks],
"title": issue.title, "supports": [{
"description": issue.description, "id": s.id, "title": s.title, "description": s.description,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status, "status": s.status.value, "priority": s.priority.value, "created_at": s.created_at,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority, } for s in supports],
"created_at": issue.created_at, "meetings": [{
} "id": m.id, "title": m.title, "description": m.description,
if issue.issue_type == ISSUE_TYPE_TASK: "status": m.status.value, "priority": m.priority.value, "created_at": m.created_at,
tasks.append(issue_data) } for m in meetings],
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}
@router.get("/{milestone_id}/progress") @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)): 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") 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() milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
# Count tasks only (not meetings or supports) all_tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
all_issues = db.query(models.Issue).filter( total = len(all_tasks)
models.Issue.milestone_id == milestone_id, completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED)
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")
progress_pct = (completed / total * 100) if total > 0 else 0 progress_pct = (completed / total * 100) if total > 0 else 0
# Calculate time progress if planned_release_date is set
time_progress = None time_progress = None
if milestone.planned_release_date: if milestone.planned_release_date and milestone.created_at:
now = datetime.now() 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() total_duration = (milestone.planned_release_date - milestone.created_at).total_seconds()
elapsed = (now - milestone.created_at).total_seconds() elapsed = (now - milestone.created_at).total_seconds()
time_progress = min(100, max(0, (elapsed / total_duration * 100))) 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, "milestone_id": milestone_id,
"title": milestone.title, "title": milestone.title,
"total": total, "total": total,
"total_tasks": total,
"completed": completed, "completed": completed,
"progress_pct": round(progress_pct, 1), "progress_pct": round(progress_pct, 1),
"time_progress_pct": round(time_progress, 1) if time_progress else None, "time_progress_pct": round(time_progress, 1) if time_progress else None,

View File

@@ -103,22 +103,19 @@ def list_activity(entity_type: str = None, entity_id: int = None, user_id: int =
return query.order_by(ActivityLog.created_at.desc()).limit(limit).all() 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"]) @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)): def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
import json import json
# Generate milestone_code: projCode:{i:05x}
project = db.query(models.Project).filter(models.Project.id == ms.project_id).first() 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}" 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() 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 next_num = (max_ms.id + 1) if max_ms else 1
milestone_code = f"{project_code}:{next_num:05x}" milestone_code = f"{project_code}:{next_num:05x}"
data = ms.model_dump() data = ms.model_dump()
# Serialize list fields to JSON strings
if data.get("depend_on_milestones"): if data.get("depend_on_milestones"):
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
else: else:
@@ -176,24 +173,14 @@ def delete_milestone(milestone_id: int, db: Session = Depends(get_db)):
return None 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"]) @router.get("/milestones/{milestone_id}/progress", tags=["Milestones"])
def milestone_progress(milestone_id: int, db: Session = Depends(get_db)): 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() ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
# Count tasks only tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
issues = db.query(models.Issue).filter( total = len(tasks)
models.Issue.milestone_id == milestone_id, done = sum(1 for t in tasks if t.status == TaskStatus.CLOSED)
models.Issue.issue_type == "task"
).all()
total = len(issues)
done = sum(1 for i in issues if i.status in ("resolved", "closed"))
time_progress = None time_progress = None
if ms.planned_release_date and ms.created_at: if ms.planned_release_date and ms.created_at:
@@ -202,162 +189,18 @@ def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
elapsed = (now - ms.created_at).total_seconds() elapsed = (now - ms.created_at).total_seconds()
time_progress = min(100, max(0, (elapsed / total_duration * 100))) 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 { return {
"id": issue.id, "milestone_id": milestone_id,
"title": issue.title, "title": ms.title,
"description": issue.description, "total": total,
"task_code": issue.task_code, "total_tasks": total,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status, "completed": done,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority, "progress_pct": round(done / total * 100, 1) if total else 0,
"created_at": issue.created_at, "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 ============ # ============ Notifications ============
class NotificationResponse(BaseModel): class NotificationResponse(BaseModel):
@@ -368,10 +211,24 @@ class NotificationResponse(BaseModel):
message: str | None = None message: str | None = None
entity_type: str | None = None entity_type: str | None = None
entity_id: int | None = None entity_id: int | None = None
task_id: int | None = None
is_read: bool is_read: bool
created_at: datetime 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"]) @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) query = db.query(NotificationModel).filter(NotificationModel.user_id == current_user.id)
if unread_only: if unread_only:
query = query.filter(NotificationModel.is_read == False) 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"]) @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 ============ # ============ Work Logs ============
class WorkLogCreate(BaseModel): class WorkLogCreate(BaseModel):
issue_id: int task_id: int
user_id: int user_id: int
hours: float hours: float
description: str | None = None description: str | None = None
@@ -422,7 +280,7 @@ class WorkLogCreate(BaseModel):
class WorkLogResponse(BaseModel): class WorkLogResponse(BaseModel):
id: int id: int
issue_id: int task_id: int
user_id: int user_id: int
hours: float hours: float
description: str | None = None 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"]) @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)): def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == wl.issue_id).first() task = db.query(Task).filter(Task.id == wl.task_id).first()
if not issue: if not task:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Task not found")
user = db.query(models.User).filter(models.User.id == wl.user_id).first() user = db.query(models.User).filter(models.User.id == wl.user_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") 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 return db_wl
@router.get("/issues/{issue_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"]) @router.get("/tasks/{task_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"])
def list_issue_worklogs(issue_id: int, db: Session = Depends(get_db)): def list_task_worklogs(task_id: int, db: Session = Depends(get_db)):
return db.query(WorkLog).filter(WorkLog.issue_id == issue_id).order_by(WorkLog.logged_date.desc()).all() 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"]) @router.get("/tasks/{task_id}/worklogs/summary", tags=["Time Tracking"])
def issue_worklog_summary(issue_id: int, db: Session = Depends(get_db)): def task_worklog_summary(task_id: int, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() task = db.query(Task).filter(Task.id == task_id).first()
if not issue: if not task:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Task not found")
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.issue_id == issue_id).scalar() or 0 total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == task_id).scalar() or 0
count = db.query(WorkLog).filter(WorkLog.issue_id == issue_id).count() count = db.query(WorkLog).filter(WorkLog.task_id == task_id).count()
return {"issue_id": issue_id, "total_hours": round(total, 2), "log_count": 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"]) @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 ============ # ============ Export ============
@router.get("/export/issues", tags=["Export"]) @router.get("/export/tasks", tags=["Export"])
def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)): def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(models.Issue) query = db.query(Task)
if project_id: if project_id:
query = query.filter(models.Issue.project_id == project_id) query = query.filter(Task.project_id == project_id)
issues = query.all() tasks = query.all()
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output) writer = csv.writer(output)
writer.writerow(["id", "title", "type", "subtype", "status", "priority", "project_id", 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"]) "tags", "created_at", "updated_at"])
for i in issues: for t in tasks:
writer.writerow([i.id, i.title, i.issue_type, i.issue_subtype or "", i.status, i.priority, i.project_id, writer.writerow([t.id, t.title, t.task_type, t.task_subtype or "",
i.reporter_id, i.assignee_id, i.milestone_id, i.due_date, t.status.value if hasattr(t.status, 'value') else t.status,
i.tags, i.created_at, i.updated_at]) 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) output.seek(0)
return StreamingResponse(iter([output.getvalue()]), media_type="text/csv", return StreamingResponse(iter([output.getvalue()]), media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=issues.csv"}) headers={"Content-Disposition": "attachment; filename=tasks.csv"})
# ============ Dashboard ============ # ============ Dashboard ============
@router.get("/dashboard/stats", tags=["Dashboard"]) @router.get("/dashboard/stats", tags=["Dashboard"])
def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)): def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(models.Issue) query = db.query(Task)
if project_id: if project_id:
query = query.filter(models.Issue.project_id == project_id) query = query.filter(Task.project_id == project_id)
total = query.count() total = query.count()
by_status = {s: query.filter(models.Issue.status == s).count() by_status = {s.value: query.filter(Task.status == s).count() for s in TaskStatus}
for s in ["open", "in_progress", "resolved", "closed", "blocked"]} by_type = {t: query.filter(Task.task_type == t).count()
by_type = {t: query.filter(models.Issue.issue_type == t).count() for t in ["task", "story", "test", "resolution", "issue", "maintenance", "research", "review"]}
for t in ["task", "story", "test", "resolution"]} by_priority = {p.value: query.filter(Task.priority == p).count() for p in TaskPriority}
by_priority = {p: query.filter(models.Issue.priority == p).count() recent = query.order_by(Task.created_at.desc()).limit(10).all()
for p in ["low", "medium", "high", "critical"]} return {
return {"total": total, "by_status": by_status, "by_type": by_type, "by_priority": by_priority} "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"]) @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() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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, "status": t.status.value if hasattr(t.status, "value") else t.status,
"priority": t.priority.value if hasattr(t.priority, "value") else t.priority, "priority": t.priority.value if hasattr(t.priority, "value") else t.priority,
"task_code": t.task_code, "task_code": t.task_code,
"task_type": t.task_type,
"task_subtype": t.task_subtype,
"estimated_effort": t.estimated_effort, "estimated_effort": t.estimated_effort,
"estimated_working_time": str(t.estimated_working_time) if t.estimated_working_time else None, "estimated_working_time": str(t.estimated_working_time) if t.estimated_working_time else None,
"started_on": t.started_on, "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"]) @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)): 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)):
from datetime import datetime
project = db.query(models.Project).filter(models.Project.project_code == project_code).first() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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": 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") 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}" 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() 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 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"), description=task_data.get("description"),
status=TaskStatus.OPEN, status=TaskStatus.OPEN,
priority=TaskPriority.MEDIUM, priority=TaskPriority.MEDIUM,
task_type=task_data.get("task_type", "task"),
task_subtype=task_data.get("task_subtype"),
project_id=project.id, project_id=project.id,
milestone_id=milestone_id, milestone_id=milestone_id,
reporter_id=current_user.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 ============ # ============ Supports ============
@router.get("/supports/{project_code}/{milestone_id}", tags=["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": 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") 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}" 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() 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 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"]) @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)): 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() project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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": 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") 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}" 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() 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 next_num = (max_meeting.id + 1) if max_meeting else 1

View File

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

View File

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

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

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

View File

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

View File

@@ -100,11 +100,11 @@ DEFAULT_PERMISSIONS = [
("project.write", "Edit project", "project"), ("project.write", "Edit project", "project"),
("project.delete", "Delete project", "project"), ("project.delete", "Delete project", "project"),
("project.manage_members", "Manage project members", "project"), ("project.manage_members", "Manage project members", "project"),
# Issue/Milestone permissions # Task/Milestone permissions
("issue.create", "Create issues", "issue"), ("task.create", "Create tasks", "task"),
("issue.read", "View issues", "issue"), ("task.read", "View tasks", "task"),
("issue.write", "Edit issues", "issue"), ("task.write", "Edit tasks", "task"),
("issue.delete", "Delete issues", "issue"), ("task.delete", "Delete tasks", "task"),
("milestone.create", "Create milestones", "milestone"), ("milestone.create", "Create milestones", "milestone"),
("milestone.read", "View milestones", "milestone"), ("milestone.read", "View milestones", "milestone"),
("milestone.write", "Edit milestones", "milestone"), ("milestone.write", "Edit milestones", "milestone"),

View File

@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
app = FastAPI( app = FastAPI(
title="HarborForge API", title="HarborForge API",
description="Agent/人类协同任务管理平台 API", description="Agent/人类协同任务管理平台 API",
version="0.2.0" version="0.3.0"
) )
# CORS # CORS
@@ -24,11 +24,11 @@ def health_check():
@app.get("/version", tags=["System"]) @app.get("/version", tags=["System"])
def version(): def version():
return {"name": "HarborForge", "version": "0.2.0", "description": "Agent/人类协同任务管理平台"} return {"name": "HarborForge", "version": "0.3.0", "description": "Agent/人类协同任务管理平台"}
# Register routers # Register routers
from app.api.routers.auth import router as auth_router 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.projects import router as projects_router
from app.api.routers.users import router as users_router from app.api.routers.users import router as users_router
from app.api.routers.comments import router as comments_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 from app.api.routers.roles import router as roles_router
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(issues_router) app.include_router(tasks_router)
app.include_router(projects_router) app.include_router(projects_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(comments_router) app.include_router(comments_router)
@@ -54,33 +54,113 @@ app.include_router(roles_router)
def _migrate_schema(): def _migrate_schema():
from sqlalchemy import text from sqlalchemy import text
from app.core.config import SessionLocal 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() db = SessionLocal()
try: 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 # projects.project_code
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'")).fetchone() result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
if not result: if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL")) 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)")) db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
# projects.owner_name # projects.owner_name
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")).fetchone() result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
if not result: if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN owner_name VARCHAR(128) NOT NULL DEFAULT ''")) db.execute(text("ALTER TABLE projects ADD COLUMN owner_name VARCHAR(128) NOT NULL DEFAULT ''"))
# projects.sub_projects / related_projects # projects.sub_projects / related_projects
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'")).fetchone() result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'"))
if not result: if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN sub_projects VARCHAR(512) NULL")) 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() result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'"))
if not result: if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN related_projects VARCHAR(512) NULL")) 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: except Exception as e:
db.rollback()
print(f"Migration warning: {e}") print(f"Migration warning: {e}")
finally: finally:
db.close() db.close()
@@ -89,7 +169,7 @@ def _migrate_schema():
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
from app.core.config import Base, engine, SessionLocal 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) Base.metadata.create_all(bind=engine)
_migrate_schema() _migrate_schema()

View File

@@ -7,8 +7,8 @@ class ActivityLog(Base):
__tablename__ = "activity_logs" __tablename__ = "activity_logs"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
action = Column(String(50), nullable=False) # e.g. "issue.created", "comment.added" action = Column(String(50), nullable=False) # e.g. "task.created", "comment.added"
entity_type = Column(String(50), nullable=False) # "issue", "project", "comment" entity_type = Column(String(50), nullable=False) # "task", "project", "comment"
entity_id = Column(Integer, nullable=False) entity_id = Column(Integer, nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
details = Column(Text, nullable=True) # JSON string details = Column(Text, nullable=True) # JSON string

View File

@@ -6,96 +6,42 @@ from app.models.role_permission import Role
import enum import enum
class IssueType(str, enum.Enum): class TaskType(str, enum.Enum):
MEETING = "meeting" """Task type enum — 'issue' is a subtype of task, not the other way around."""
SUPPORT = "support"
ISSUE = "issue" ISSUE = "issue"
MAINTENANCE = "maintenance" MAINTENANCE = "maintenance"
RESEARCH = "research" RESEARCH = "research"
REVIEW = "review" REVIEW = "review"
STORY = "story" STORY = "story"
TEST = "test" TEST = "test"
RESOLUTION = "resolution" # 决议案 - 用于 Agent 僵局提交 RESOLUTION = "resolution"
TASK = "task" # legacy generic type TASK = "task"
class IssueStatus(str, enum.Enum): class TaskStatus(str, enum.Enum):
OPEN = "open" OPEN = "open"
IN_PROGRESS = "in_progress" PENDING = "pending"
RESOLVED = "resolved" PROGRESSING = "progressing"
CLOSED = "closed" CLOSED = "closed"
BLOCKED = "blocked"
class IssuePriority(str, enum.Enum): class TaskPriority(str, enum.Enum):
LOW = "low" LOW = "low"
MEDIUM = "medium" MEDIUM = "medium"
HIGH = "high" HIGH = "high"
CRITICAL = "critical" 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): class Comment(Base):
__tablename__ = "comments" __tablename__ = "comments"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
content = Column(Text, nullable=False) 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) author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
issue = relationship("Issue", back_populates="comments")
author = relationship("User", 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) 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") members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
owner = relationship("User", back_populates="owned_projects") owner = relationship("User", back_populates="owned_projects")
@@ -125,15 +70,13 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False) username = Column(String(50), unique=True, nullable=False)
email = Column(String(100), 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) full_name = Column(String(100), nullable=True)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
owned_projects = relationship("Project", back_populates="owner") 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") comments = relationship("Comment", back_populates="author")
project_memberships = relationship("ProjectMember", back_populates="user") project_memberships = relationship("ProjectMember", back_populates="user")

View File

@@ -9,10 +9,10 @@ class Notification(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) 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) title = Column(String(255), nullable=False)
message = Column(Text, nullable=True) 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) entity_id = Column(Integer, nullable=True)
is_read = Column(Boolean, default=False) is_read = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -26,18 +26,35 @@ class Task(Base):
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM) priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM)
task_code = Column(String(64), nullable=True, unique=True, index=True) 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) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=False) milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=False)
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False) reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True) assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_by_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) depend_on = Column(Text, nullable=True)
related_tasks = Column(Text, nullable=True)
# Effort tracking
estimated_effort = Column(Integer, nullable=True) estimated_effort = Column(Integer, nullable=True)
estimated_working_time = Column(Time, nullable=True) estimated_working_time = Column(Time, nullable=True)
started_on = Column(DateTime(timezone=True), nullable=True) started_on = Column(DateTime(timezone=True), nullable=True)
finished_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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
comments = relationship("Comment", foreign_keys="Comment.task_id", cascade="all, delete-orphan")

View File

@@ -5,10 +5,10 @@ import enum
class WebhookEvent(str, enum.Enum): class WebhookEvent(str, enum.Enum):
ISSUE_CREATED = "issue.created" TASK_CREATED = "task.created"
ISSUE_UPDATED = "issue.updated" TASK_UPDATED = "task.updated"
ISSUE_CLOSED = "issue.closed" TASK_CLOSED = "task.closed"
ISSUE_DELETED = "issue.deleted" TASK_DELETED = "task.deleted"
COMMENT_CREATED = "comment.created" COMMENT_CREATED = "comment.created"
RESOLUTION_CREATED = "resolution.created" RESOLUTION_CREATED = "resolution.created"
MEMBER_ADDED = "member.added" MEMBER_ADDED = "member.added"

View File

@@ -7,9 +7,9 @@ class WorkLog(Base):
__tablename__ = "work_logs" __tablename__ = "work_logs"
id = Column(Integer, primary_key=True, index=True) 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) 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) 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()) created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -4,9 +4,7 @@ from datetime import datetime
from enum import Enum from enum import Enum
class IssueTypeEnum(str, Enum): class TaskTypeEnum(str, Enum):
MEETING = "meeting"
SUPPORT = "support"
ISSUE = "issue" ISSUE = "issue"
MAINTENANCE = "maintenance" MAINTENANCE = "maintenance"
RESEARCH = "research" RESEARCH = "research"
@@ -14,40 +12,39 @@ class IssueTypeEnum(str, Enum):
STORY = "story" STORY = "story"
TEST = "test" TEST = "test"
RESOLUTION = "resolution" RESOLUTION = "resolution"
TASK = "task" # legacy TASK = "task"
class IssueStatusEnum(str, Enum): class TaskStatusEnum(str, Enum):
OPEN = "open" OPEN = "open"
IN_PROGRESS = "in_progress" PENDING = "pending"
RESOLVED = "resolved" PROGRESSING = "progressing"
CLOSED = "closed" CLOSED = "closed"
BLOCKED = "blocked"
class IssuePriorityEnum(str, Enum): class TaskPriorityEnum(str, Enum):
LOW = "low" LOW = "low"
MEDIUM = "medium" MEDIUM = "medium"
HIGH = "high" HIGH = "high"
CRITICAL = "critical" CRITICAL = "critical"
# Issue schemas # Task schemas
class IssueBase(BaseModel): class TaskBase(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
issue_type: IssueTypeEnum = IssueTypeEnum.ISSUE task_type: TaskTypeEnum = TaskTypeEnum.TASK
issue_subtype: Optional[str] = None task_subtype: Optional[str] = None
priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM priority: TaskPriorityEnum = TaskPriorityEnum.MEDIUM
tags: Optional[str] = None tags: Optional[str] = None
depends_on_id: Optional[int] = None estimated_effort: Optional[int] = None
due_date: Optional[datetime] = None estimated_working_time: Optional[str] = None
class TaskCreate(TaskBase):
project_id: Optional[int] = None
milestone_id: Optional[int] = None milestone_id: Optional[int] = None
reporter_id: Optional[int] = None
class IssueCreate(IssueBase):
project_id: int
reporter_id: int
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
# Resolution specific # Resolution specific
resolution_summary: Optional[str] = None resolution_summary: Optional[str] = None
@@ -55,37 +52,35 @@ class IssueCreate(IssueBase):
pending_matters: Optional[str] = None pending_matters: Optional[str] = None
class IssueUpdate(BaseModel): class TaskUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
issue_type: Optional[IssueTypeEnum] = None task_type: Optional[TaskTypeEnum] = None
issue_subtype: Optional[str] = None task_subtype: Optional[str] = None
status: Optional[IssueStatusEnum] = None status: Optional[TaskStatusEnum] = None
priority: Optional[IssuePriorityEnum] = None priority: Optional[TaskPriorityEnum] = None
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
tags: Optional[str] = None tags: Optional[str] = None
depends_on_id: Optional[int] = None estimated_effort: Optional[int] = None
due_date: Optional[datetime] = None
milestone_id: Optional[int] = None
# Resolution specific # Resolution specific
resolution_summary: Optional[str] = None resolution_summary: Optional[str] = None
positions: Optional[str] = None positions: Optional[str] = None
pending_matters: Optional[str] = None pending_matters: Optional[str] = None
class IssueResponse(IssueBase): class TaskResponse(TaskBase):
id: int id: int
status: IssueStatusEnum status: TaskStatusEnum
task_code: Optional[str] = None
project_id: int project_id: int
milestone_id: int
reporter_id: int reporter_id: int
assignee_id: Optional[int] assignee_id: Optional[int] = None
resolution_summary: Optional[str] resolution_summary: Optional[str] = None
positions: Optional[str] positions: Optional[str] = None
pending_matters: Optional[str] pending_matters: Optional[str] = None
due_date: Optional[datetime] = None
milestone_id: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] updated_at: Optional[datetime] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -97,7 +92,7 @@ class CommentBase(BaseModel):
class CommentCreate(CommentBase): class CommentCreate(CommentBase):
issue_id: int task_id: int
author_id: int author_id: int
@@ -107,10 +102,10 @@ class CommentUpdate(BaseModel):
class CommentResponse(CommentBase): class CommentResponse(CommentBase):
id: int id: int
issue_id: int task_id: int
author_id: int author_id: int
created_at: datetime created_at: datetime
updated_at: Optional[datetime] updated_at: Optional[datetime] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -147,15 +142,6 @@ class ProjectResponse(BaseModel):
owner_id: int owner_id: int
created_at: datetime 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 # User schemas
class UserBase(BaseModel): class UserBase(BaseModel):

View File

@@ -6,7 +6,7 @@ from datetime import datetime
class WebhookCreate(BaseModel): class WebhookCreate(BaseModel):
url: str url: str
secret: Optional[str] = None 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 project_id: Optional[int] = None
is_active: bool = True is_active: bool = True

View File

@@ -5,7 +5,7 @@ from typing import Any, Dict, Tuple
import requests import requests
from sqlalchemy.orm import Session 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 from app.models.monitor import ProviderAccount, ProviderUsageSnapshot, MonitoredServer, ServerState
_CACHE: Dict[str, Dict[str, Any]] = {} _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): def get_task_stats_cached(db: Session, ttl_seconds: int = 1800):
key = 'issue_stats_24h' key = 'task_stats_24h'
now = _now() now = _now()
hit = _CACHE.get(key) hit = _CACHE.get(key)
if hit and (now - hit['at']).total_seconds() < ttl_seconds: if hit and (now - hit['at']).total_seconds() < ttl_seconds:
return hit['data'] return hit['data']
since = now - timedelta(hours=24) since = now - timedelta(hours=24)
total = db.query(Issue).count() total = db.query(Task).count()
new_24h = db.query(Issue).filter(Issue.created_at >= since).count() new_24h = db.query(Task).filter(Task.created_at >= since).count()
processed_24h = db.query(Issue).filter( processed_24h = db.query(Task).filter(
Issue.updated_at != None, Task.updated_at != None,
Issue.updated_at >= since, Task.updated_at >= since,
Issue.status.in_(['resolved', 'closed']) Task.status == TaskStatus.CLOSED,
).count() ).count()
data = { data = {
'total_issues': total, 'total_tasks': total,
'new_issues_24h': new_24h, 'new_tasks_24h': new_24h,
'processed_issues_24h': processed_24h, 'processed_tasks_24h': processed_24h,
'computed_at': now.isoformat(), 'computed_at': now.isoformat(),
'cache_ttl_seconds': ttl_seconds, 'cache_ttl_seconds': ttl_seconds,
} }

233
cli.py
View File

@@ -5,27 +5,47 @@ import argparse
import json import json
import os import os
import sys import sys
import urllib.request
import urllib.error import urllib.error
import urllib.parse
import urllib.request
BASE_URL = os.environ.get("HARBORFORGE_URL", "http://localhost:8000") BASE_URL = os.environ.get("HARBORFORGE_URL", "http://localhost:8000")
TOKEN = os.environ.get("HARBORFORGE_TOKEN", "") 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): def _request(method, path, data=None):
url = f"{BASE_URL}{path}" url = f"{BASE_URL}{path}"
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
if TOKEN: if TOKEN:
headers["Authorization"] = f"Bearer {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) req = urllib.request.Request(url, data=body, headers=headers, method=method)
try: try:
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req) as resp:
if resp.status == 204: if resp.status == 204:
return None return None
return json.loads(resp.read()) raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr) print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -45,36 +65,39 @@ def cmd_login(args):
sys.exit(1) sys.exit(1)
def cmd_issues(args): def cmd_tasks(args):
params = [] params = []
if args.project: if args.project:
params.append(f"project_id={args.project}") params.append(f"project_id={args.project}")
if args.type: if args.type:
params.append(f"issue_type={args.type}") params.append(f"task_type={args.type}")
if args.status: if args.status:
params.append(f"issue_status={args.status}") params.append(f"task_status={args.status}")
qs = f"?{'&'.join(params)}" if params else "" qs = f"?{'&'.join(params)}" if params else ""
issues = _request("GET", f"/issues{qs}") result = _request("GET", f"/tasks{qs}")
for i in issues: items = result.get("items", result if isinstance(result, list) else [])
status_icon = {"open": "🟢", "in_progress": "🔵", "resolved": "", "closed": "", "blocked": "🔴"}.get(i["status"], "") for task in items:
type_icon = {"resolution": "⚖️", "task": "📋", "story": "📖", "test": "🧪"}.get(i["issue_type"], "📌") status_icon = STATUS_ICON.get(task["status"], "")
print(f" {status_icon} {type_icon} #{i['id']} [{i['priority']}] {i['title']}") 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 = { data = {
"title": args.title, "title": args.title,
"project_id": args.project, "project_id": args.project,
"milestone_id": args.milestone,
"reporter_id": args.reporter, "reporter_id": args.reporter,
"issue_type": args.type, "task_type": args.type,
"priority": args.priority or "medium", "priority": args.priority or "medium",
} }
if args.description: if args.description:
data["description"] = args.description data["description"] = args.description
if args.assignee: if args.assignee:
data["assignee_id"] = args.assignee data["assignee_id"] = args.assignee
if args.subtype:
data["task_subtype"] = args.subtype
# Resolution specific
if args.type == "resolution": if args.type == "resolution":
if args.summary: if args.summary:
data["resolution_summary"] = args.summary data["resolution_summary"] = args.summary
@@ -83,21 +106,21 @@ def cmd_issue_create(args):
if args.pending: if args.pending:
data["pending_matters"] = args.pending data["pending_matters"] = args.pending
result = _request("POST", "/issues", data) result = _request("POST", "/tasks", data)
print(f"Created issue #{result['id']}: {result['title']}") print(f"Created task #{result['id']}: {result['title']}")
def cmd_projects(args): def cmd_projects(args):
projects = _request("GET", "/projects") projects = _request("GET", "/projects")
for p in projects: for project in projects:
print(f" #{p['id']} {p['name']} - {p.get('description', '')}") print(f" #{project['id']} {project['name']} - {project.get('description', '')}")
def cmd_users(args): def cmd_users(args):
users = _request("GET", "/users") users = _request("GET", "/users")
for u in users: for user in users:
role = "👑" if u["is_admin"] else "👤" role = "👑" if user["is_admin"] else "👤"
print(f" {role} #{u['id']} {u['username']} ({u.get('full_name', '')})") print(f" {role} #{user['id']} {user['username']} ({user.get('full_name', '')})")
def cmd_version(args): def cmd_version(args):
@@ -110,41 +133,38 @@ def cmd_health(args):
print(f"Status: {result['status']}") print(f"Status: {result['status']}")
def cmd_search(args): def cmd_search(args):
params = [f"q={args.query}"] params = [f"q={urllib.parse.quote(args.query)}"]
if args.project: if args.project:
params.append(f"project_id={args.project}") params.append(f"project_id={args.project}")
qs = "&".join(params) result = _request("GET", f"/search/tasks?{'&'.join(params)}")
issues = _request("GET", f"/search/issues?{qs}") items = result.get("items", result if isinstance(result, list) else [])
if not issues: if not items:
print(" No results found.") print(" No results found.")
return return
for i in issues: for task in items:
status_icon = {"open": "\U0001f7e2", "in_progress": "\U0001f535", "resolved": "\u2705", "closed": "\u26ab", "blocked": "\U0001f534"}.get(i["status"], "\u2753") status_icon = STATUS_ICON.get(task["status"], "")
type_icon = {"resolution": "\u2696\ufe0f", "task": "\U0001f4cb", "story": "\U0001f4d6", "test": "\U0001f9ea"}.get(i["issue_type"], "\U0001f4cc") type_icon = TYPE_ICON.get(task.get("task_type"), "📌")
print(f" {status_icon} {type_icon} #{i['id']} [{i['priority']}] {i['title']}") print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}")
def cmd_transition(args): def cmd_transition(args):
result = _request("POST", f"/issues/{args.issue_id}/transition?new_status={args.status}") result = _request("POST", f"/tasks/{args.task_id}/transition?new_status={args.status}")
print(f"Issue #{result['id']} transitioned to: {result['status']}") print(f"Task #{result['id']} transitioned to: {result['status']}")
def cmd_stats(args): def cmd_stats(args):
params = f"?project_id={args.project}" if args.project else "" params = f"?project_id={args.project}" if args.project else ""
stats = _request("GET", f"/dashboard/stats{params}") stats = _request("GET", f"/dashboard/stats{params}")
print(f"Total: {stats['total']}") print(f"Total: {stats['total_tasks']}")
print("By status:") print("By status:")
for s, c in stats["by_status"].items(): for status_name, count in stats["by_status"].items():
if c > 0: if count > 0:
print(f" {s}: {c}") print(f" {status_name}: {count}")
print("By type:") print("By type:")
for t, c in stats["by_type"].items(): for task_type, count in stats["by_type"].items():
if c > 0: if count > 0:
print(f" {t}: {c}") print(f" {task_type}: {count}")
def cmd_milestones(args): def cmd_milestones(args):
@@ -153,10 +173,10 @@ def cmd_milestones(args):
if not milestones: if not milestones:
print(" No milestones found.") print(" No milestones found.")
return return
for m in milestones: for milestone in milestones:
status_icon = "🟢" if m["status"] == "open" else "" status_icon = STATUS_ICON.get(milestone["status"], "")
due = f" (due: {m['due_date'][:10]})" if m.get("due_date") else "" due = f" (due: {milestone['due_date'][:10]})" if milestone.get("due_date") else ""
print(f" {status_icon} #{m['id']} {m['title']}{due}") print(f" {status_icon} #{milestone['id']} {milestone['title']}{due}")
def cmd_milestone_progress(args): def cmd_milestone_progress(args):
@@ -165,140 +185,114 @@ def cmd_milestone_progress(args):
filled = int(bar_len * result["progress_pct"] / 100) filled = int(bar_len * result["progress_pct"] / 100)
bar = "" * filled + "" * (bar_len - filled) bar = "" * filled + "" * (bar_len - filled)
print(f" {result['title']}") 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): def cmd_notifications(args):
params = [f"user_id={args.user}"] params = []
if args.unread: if args.unread:
params.append("unread_only=true") params.append("unread_only=true")
qs = "&".join(params) qs = f"?{'&'.join(params)}" if params else ""
notifs = _request("GET", f"/notifications?{qs}") notifications = _request("GET", f"/notifications{qs}")
if not notifs: if not notifications:
print(" No notifications.") print(" No notifications.")
return return
for n in notifs: for notification in notifications:
icon = "🔴" if not n["is_read"] else "" icon = "🔴" if not notification["is_read"] else ""
print(f" {icon} [{n['type']}] {n['title']}") print(f" {icon} [{notification['type']}] {notification.get('message') or notification['title']}")
def cmd_overdue(args): def cmd_overdue(args):
params = f"?project_id={args.project}" if args.project else "" print("Overdue tasks are not supported by the current milestone-based task schema.")
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})")
def cmd_log_time(args): def cmd_log_time(args):
from datetime import datetime from datetime import datetime
data = { data = {
'issue_id': args.issue_id, "task_id": args.task_id,
'user_id': args.user_id, "user_id": args.user_id,
'hours': args.hours, "hours": args.hours,
'logged_date': datetime.utcnow().isoformat(), "logged_date": datetime.utcnow().isoformat(),
} }
if args.desc: if args.desc:
data['description'] = args.desc data["description"] = args.desc
r = api('POST', '/worklogs', json=data) result = _request("POST", "/worklogs", data)
print(f'Logged {r["hours"]}h on issue #{r["issue_id"]} (log #{r["id"]})') print(f"Logged {result['hours']}h on task #{result['task_id']} (log #{result['id']})")
def cmd_worklogs(args): def cmd_worklogs(args):
logs = api('GET', f'/issues/{args.issue_id}/worklogs') logs = _request("GET", f"/tasks/{args.task_id}/worklogs")
for l in logs: for log in logs:
desc = f' - {l["description"]}' if l.get('description') else '' desc = f" - {log['description']}" if log.get("description") else ""
print(f' [{l["id"]}] {l["hours"]}h by user#{l["user_id"]} on {l["logged_date"]}{desc}') print(f" [{log['id']}] {log['hours']}h by user#{log['user_id']} on {log['logged_date']}{desc}")
summary = api('GET', f'/issues/{args.issue_id}/worklogs/summary') summary = _request("GET", f"/tasks/{args.task_id}/worklogs/summary")
print(f' Total: {summary["total_hours"]}h ({summary["log_count"]} logs)') print(f" Total: {summary['total_hours']}h ({summary['log_count']} logs)")
def main(): def main():
parser = argparse.ArgumentParser(description="HarborForge CLI") parser = argparse.ArgumentParser(description="HarborForge CLI")
sub = parser.add_subparsers(dest="command") sub = parser.add_subparsers(dest="command")
# login
p_login = sub.add_parser("login", help="Login and get token") p_login = sub.add_parser("login", help="Login and get token")
p_login.add_argument("username") p_login.add_argument("username")
p_login.add_argument("password") p_login.add_argument("password")
# issues p_tasks = sub.add_parser("tasks", aliases=["issues"], help="List tasks")
p_issues = sub.add_parser("issues", help="List issues") p_tasks.add_argument("--project", "-p", type=int)
p_issues.add_argument("--project", "-p", type=int) p_tasks.add_argument("--type", "-t", choices=["task", "story", "test", "resolution", "issue", "maintenance", "research", "review"])
p_issues.add_argument("--type", "-t", choices=["task", "story", "test", "resolution"]) p_tasks.add_argument("--status", "-s", choices=["open", "pending", "progressing", "closed"])
p_issues.add_argument("--status", "-s")
# issue create p_create = sub.add_parser("create-task", aliases=["create-issue"], help="Create a task")
p_create = sub.add_parser("create-issue", help="Create an issue")
p_create.add_argument("title") p_create.add_argument("title")
p_create.add_argument("--project", "-p", type=int, required=True) 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("--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("--priority", choices=["low", "medium", "high", "critical"])
p_create.add_argument("--description", "-d") p_create.add_argument("--description", "-d")
p_create.add_argument("--assignee", "-a", type=int) p_create.add_argument("--assignee", "-a", type=int)
# Resolution fields
p_create.add_argument("--summary") p_create.add_argument("--summary")
p_create.add_argument("--positions") p_create.add_argument("--positions")
p_create.add_argument("--pending") p_create.add_argument("--pending")
# projects
sub.add_parser("projects", help="List projects") sub.add_parser("projects", help="List projects")
# users
sub.add_parser("users", help="List users") sub.add_parser("users", help="List users")
# version
sub.add_parser("version", help="Show version") sub.add_parser("version", help="Show version")
# health
sub.add_parser("health", help="Health check") sub.add_parser("health", help="Health check")
p_search = sub.add_parser("search", help="Search tasks")
# search
p_search = sub.add_parser("search", help="Search issues")
p_search.add_argument("query") p_search.add_argument("query")
p_search.add_argument("--project", "-p", type=int) p_search.add_argument("--project", "-p", type=int)
# transition p_trans = sub.add_parser("transition", help="Transition task status")
p_trans = sub.add_parser("transition", help="Transition issue status") p_trans.add_argument("task_id", type=int)
p_trans.add_argument("issue_id", type=int) p_trans.add_argument("status", choices=["open", "pending", "progressing", "closed"])
p_trans.add_argument("status", choices=["open", "in_progress", "resolved", "closed", "blocked"])
# stats
p_stats = sub.add_parser("stats", help="Dashboard stats") p_stats = sub.add_parser("stats", help="Dashboard stats")
p_stats.add_argument("--project", "-p", type=int) p_stats.add_argument("--project", "-p", type=int)
# milestones
p_ms = sub.add_parser("milestones", help="List milestones") p_ms = sub.add_parser("milestones", help="List milestones")
p_ms.add_argument("--project", "-p", type=int) p_ms.add_argument("--project", "-p", type=int)
# milestone progress
p_msp = sub.add_parser("milestone-progress", help="Show milestone progress") p_msp = sub.add_parser("milestone-progress", help="Show milestone progress")
p_msp.add_argument("milestone_id", type=int) p_msp.add_argument("milestone_id", type=int)
# notifications p_notif = sub.add_parser("notifications", help="List notifications for current token user")
p_notif = sub.add_parser("notifications", help="List notifications")
p_notif.add_argument("--user", "-u", type=int, required=True)
p_notif.add_argument("--unread", action="store_true") p_notif.add_argument("--unread", action="store_true")
# overdue p_overdue = sub.add_parser("overdue", help="Explain overdue-task support status")
p_overdue = sub.add_parser("overdue", help="List overdue issues")
p_overdue.add_argument("--project", "-p", type=int) p_overdue.add_argument("--project", "-p", type=int)
p_logtime = sub.add_parser('log-time', help='Log time on an issue') p_logtime = sub.add_parser("log-time", help="Log time on a task")
p_logtime.add_argument('issue_id', type=int) p_logtime.add_argument("task_id", type=int)
p_logtime.add_argument('user_id', type=int) p_logtime.add_argument("user_id", type=int)
p_logtime.add_argument('hours', type=float) p_logtime.add_argument("hours", type=float)
p_logtime.add_argument('--desc', '-d', type=str) p_logtime.add_argument("--desc", "-d", type=str)
p_worklogs = sub.add_parser('worklogs', help='List work logs for an issue') p_worklogs = sub.add_parser("worklogs", help="List work logs for a task")
p_worklogs.add_argument('issue_id', type=int) p_worklogs.add_argument("task_id", type=int)
args = parser.parse_args() args = parser.parse_args()
if not args.command: if not args.command:
@@ -307,8 +301,10 @@ def main():
cmds = { cmds = {
"login": cmd_login, "login": cmd_login,
"issues": cmd_issues, "tasks": cmd_tasks,
"create-issue": cmd_issue_create, "issues": cmd_tasks,
"create-task": cmd_task_create,
"create-issue": cmd_task_create,
"projects": cmd_projects, "projects": cmd_projects,
"users": cmd_users, "users": cmd_users,
"version": cmd_version, "version": cmd_version,
@@ -327,5 +323,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
import urllib.parse
main() main()