Merge pull request 'Merge dev-2026-03-22 into main' (#12) from dev-2026-03-22 into main
Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
24
README.md
24
README.md
@@ -98,29 +98,9 @@ Agent/人类协同任务管理平台 - FastAPI 后端
|
|||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
```bash
|
The legacy Python CLI (`cli.py`) has been retired. Use the Go-based `hf` CLI instead.
|
||||||
# 环境变量
|
|
||||||
export HARBORFORGE_URL=http://localhost:8000
|
|
||||||
export HARBORFORGE_TOKEN=<your-token>
|
|
||||||
|
|
||||||
# 命令
|
See [HarborForge.Cli](../HarborForge.Cli/README.md) for installation and usage.
|
||||||
python3 cli.py login <username> <password>
|
|
||||||
python3 cli.py issues [-p project_id] [-t type] [-s status]
|
|
||||||
python3 cli.py create-issue "title" -p 1 -r 1 [-t resolution --summary "..." --positions "..." --pending "..."]
|
|
||||||
python3 cli.py search "keyword"
|
|
||||||
python3 cli.py transition <issue_id> <new_status>
|
|
||||||
python3 cli.py stats [-p project_id]
|
|
||||||
python3 cli.py projects
|
|
||||||
python3 cli.py users
|
|
||||||
python3 cli.py milestones [-p project_id]
|
|
||||||
python3 cli.py milestone-progress <milestone_id>
|
|
||||||
python3 cli.py notifications -u <user_id> [--unread]
|
|
||||||
python3 cli.py overdue [-p project_id]
|
|
||||||
python3 cli.py log-time <issue_id> <user_id> <hours> [-d "description"]
|
|
||||||
python3 cli.py worklogs <issue_id>
|
|
||||||
python3 cli.py health
|
|
||||||
python3 cli.py version
|
|
||||||
```
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ def get_user_role(db: Session, user_id: int, project_id: int) -> Role | None:
|
|||||||
# Check global admin
|
# Check global admin
|
||||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
if user and user.is_admin:
|
if user and user.is_admin:
|
||||||
# Return global admin role
|
# Return global admin role (name="admin")
|
||||||
return db.query(Role).filter(Role.is_global == True, Role.name == "superadmin").first()
|
return db.query(Role).filter(Role.is_global == True, Role.name == "admin").first()
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
"""Auth router."""
|
"""Auth router."""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_db, settings
|
from app.core.config import get_db, settings
|
||||||
from app.models import models
|
from app.models import models
|
||||||
|
from app.models.role_permission import Permission, Role, RolePermission
|
||||||
from app.schemas import schemas
|
from app.schemas import schemas
|
||||||
from app.api.deps import Token, verify_password, create_access_token, get_current_user
|
from app.api.deps import Token, verify_password, create_access_token, get_current_user
|
||||||
|
|
||||||
@@ -20,6 +24,9 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session =
|
|||||||
headers={"WWW-Authenticate": "Bearer"})
|
headers={"WWW-Authenticate": "Bearer"})
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise HTTPException(status_code=400, detail="Inactive user")
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
|
# Built-in acc-mgr account cannot log in interactively
|
||||||
|
if user.username == "acc-mgr":
|
||||||
|
raise HTTPException(status_code=403, detail="This account cannot log in")
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
data={"sub": str(user.id)},
|
data={"sub": str(user.id)},
|
||||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
@@ -30,3 +37,74 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session =
|
|||||||
@router.get("/me", response_model=schemas.UserResponse)
|
@router.get("/me", response_model=schemas.UserResponse)
|
||||||
async def get_me(current_user: models.User = Depends(get_current_user)):
|
async def get_me(current_user: models.User = Depends(get_current_user)):
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyPermissionResponse(BaseModel):
|
||||||
|
can_reset_self: bool
|
||||||
|
can_reset_any: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/apikey-permissions", response_model=ApiKeyPermissionResponse)
|
||||||
|
async def get_apikey_permissions(
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Return the current user's API key reset capabilities."""
|
||||||
|
def _has_perm(perm_name: str) -> bool:
|
||||||
|
if current_user.is_admin:
|
||||||
|
return True
|
||||||
|
if not current_user.role_id:
|
||||||
|
return False
|
||||||
|
perm = db.query(Permission).filter(Permission.name == perm_name).first()
|
||||||
|
if not perm:
|
||||||
|
return False
|
||||||
|
return db.query(RolePermission).filter(
|
||||||
|
RolePermission.role_id == current_user.role_id,
|
||||||
|
RolePermission.permission_id == perm.id,
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
return ApiKeyPermissionResponse(
|
||||||
|
can_reset_self=_has_perm("user.reset-self-apikey"),
|
||||||
|
can_reset_any=_has_perm("user.reset-apikey"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionIntrospectionResponse(BaseModel):
|
||||||
|
username: str
|
||||||
|
role_name: str | None
|
||||||
|
is_admin: bool
|
||||||
|
permissions: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/permissions", response_model=PermissionIntrospectionResponse)
|
||||||
|
async def get_my_permissions(
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Return the current user's effective permissions for CLI help introspection."""
|
||||||
|
perms: List[str] = []
|
||||||
|
role_name: str | None = None
|
||||||
|
|
||||||
|
if current_user.is_admin:
|
||||||
|
# Admin gets all permissions
|
||||||
|
all_perms = db.query(Permission).order_by(Permission.name).all()
|
||||||
|
perms = [p.name for p in all_perms]
|
||||||
|
role_name = "admin"
|
||||||
|
elif current_user.role_id:
|
||||||
|
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||||
|
if role:
|
||||||
|
role_name = role.name
|
||||||
|
perm_ids = db.query(RolePermission.permission_id).filter(
|
||||||
|
RolePermission.role_id == role.id
|
||||||
|
).all()
|
||||||
|
if perm_ids:
|
||||||
|
pid_list = [p[0] for p in perm_ids]
|
||||||
|
matched = db.query(Permission).filter(Permission.id.in_(pid_list)).order_by(Permission.name).all()
|
||||||
|
perms = [p.name for p in matched]
|
||||||
|
|
||||||
|
return PermissionIntrospectionResponse(
|
||||||
|
username=current_user.username,
|
||||||
|
role_name=role_name,
|
||||||
|
is_admin=current_user.is_admin,
|
||||||
|
permissions=perms,
|
||||||
|
)
|
||||||
|
|||||||
@@ -50,9 +50,30 @@ def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)
|
|||||||
return db_comment
|
return db_comment
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}/comments", response_model=List[schemas.CommentResponse])
|
@router.get("/tasks/{task_id}/comments")
|
||||||
def list_comments(task_id: int, db: Session = Depends(get_db)):
|
def list_comments(task_id: str, db: Session = Depends(get_db)):
|
||||||
return db.query(models.Comment).filter(models.Comment.task_id == task_id).all()
|
"""List comments for a task. task_id can be numeric id or task_code."""
|
||||||
|
try:
|
||||||
|
tid = int(task_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
task = db.query(Task).filter(Task.task_code == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
tid = task.id
|
||||||
|
comments = db.query(models.Comment).filter(models.Comment.task_id == tid).all()
|
||||||
|
result = []
|
||||||
|
for c in comments:
|
||||||
|
author = db.query(models.User).filter(models.User.id == c.author_id).first()
|
||||||
|
result.append({
|
||||||
|
"id": c.id,
|
||||||
|
"content": c.content,
|
||||||
|
"task_id": c.task_id,
|
||||||
|
"author_id": c.author_id,
|
||||||
|
"author_username": author.username if author else None,
|
||||||
|
"created_at": c.created_at,
|
||||||
|
"updated_at": c.updated_at,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse)
|
@router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse)
|
||||||
|
|||||||
289
app/api/routers/meetings.py
Normal file
289
app/api/routers/meetings.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"""Meetings router — code-first CRUD with participant/attend support."""
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.config import get_db
|
||||||
|
from app.models import models
|
||||||
|
from app.models.meeting import Meeting, MeetingStatus, MeetingPriority, MeetingParticipant
|
||||||
|
from app.models.milestone import Milestone
|
||||||
|
from app.api.deps import get_current_user_or_apikey
|
||||||
|
from app.api.rbac import check_project_role
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Meetings"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---- helpers ----
|
||||||
|
|
||||||
|
def _find_meeting_by_id_or_code(db: Session, identifier: str) -> Meeting | None:
|
||||||
|
try:
|
||||||
|
mid = int(identifier)
|
||||||
|
meeting = db.query(Meeting).filter(Meeting.id == mid).first()
|
||||||
|
if meeting:
|
||||||
|
return meeting
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return db.query(Meeting).filter(Meeting.meeting_code == str(identifier)).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_project_id(db: Session, project_code: str | None) -> int | None:
|
||||||
|
if not project_code:
|
||||||
|
return None
|
||||||
|
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")
|
||||||
|
return project.id
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_milestone(db: Session, milestone_code: str | None, project_id: int | None) -> Milestone | None:
|
||||||
|
if not milestone_code:
|
||||||
|
return None
|
||||||
|
query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code)
|
||||||
|
if project_id:
|
||||||
|
query = query.filter(Milestone.project_id == project_id)
|
||||||
|
ms = query.first()
|
||||||
|
if not ms:
|
||||||
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
return ms
|
||||||
|
|
||||||
|
|
||||||
|
def _get_participant_usernames(db: Session, meeting: Meeting) -> list[str]:
|
||||||
|
parts = db.query(MeetingParticipant).filter(MeetingParticipant.meeting_id == meeting.id).all()
|
||||||
|
usernames = []
|
||||||
|
for p in parts:
|
||||||
|
user = db.query(models.User).filter(models.User.id == p.user_id).first()
|
||||||
|
if user:
|
||||||
|
usernames.append(user.username)
|
||||||
|
return usernames
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_meeting(db: Session, meeting: Meeting) -> dict:
|
||||||
|
project = db.query(models.Project).filter(models.Project.id == meeting.project_id).first()
|
||||||
|
milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first()
|
||||||
|
return {
|
||||||
|
"id": meeting.id,
|
||||||
|
"code": meeting.meeting_code,
|
||||||
|
"meeting_code": meeting.meeting_code,
|
||||||
|
"title": meeting.title,
|
||||||
|
"description": meeting.description,
|
||||||
|
"status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status,
|
||||||
|
"priority": meeting.priority.value if hasattr(meeting.priority, "value") else meeting.priority,
|
||||||
|
"project_id": meeting.project_id,
|
||||||
|
"project_code": project.project_code if project else None,
|
||||||
|
"milestone_id": meeting.milestone_id,
|
||||||
|
"milestone_code": milestone.milestone_code if milestone else None,
|
||||||
|
"reporter_id": meeting.reporter_id,
|
||||||
|
"meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None,
|
||||||
|
"scheduled_at": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None,
|
||||||
|
"duration_minutes": meeting.duration_minutes,
|
||||||
|
"participants": _get_participant_usernames(db, meeting),
|
||||||
|
"created_at": meeting.created_at.isoformat() if meeting.created_at else None,
|
||||||
|
"updated_at": meeting.updated_at.isoformat() if meeting.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- CRUD ----
|
||||||
|
|
||||||
|
class MeetingCreateBody(BaseModel):
|
||||||
|
project_code: str
|
||||||
|
title: str
|
||||||
|
milestone_code: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
meeting_time: Optional[str] = None
|
||||||
|
duration_minutes: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meetings", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_meeting(
|
||||||
|
body: MeetingCreateBody,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
project_id = _resolve_project_id(db, body.project_code)
|
||||||
|
if not project_id:
|
||||||
|
raise HTTPException(status_code=400, detail="project_code is required")
|
||||||
|
check_project_role(db, current_user.id, project_id, min_role="dev")
|
||||||
|
|
||||||
|
milestone = _resolve_milestone(db, body.milestone_code, project_id)
|
||||||
|
if not milestone:
|
||||||
|
# If no milestone_code, try to use the first active milestone
|
||||||
|
milestone = db.query(Milestone).filter(
|
||||||
|
Milestone.project_id == project_id,
|
||||||
|
).order_by(Milestone.id.desc()).first()
|
||||||
|
if not milestone:
|
||||||
|
raise HTTPException(status_code=400, detail="No milestone available for project")
|
||||||
|
|
||||||
|
milestone_code = milestone.milestone_code or f"m{milestone.id}"
|
||||||
|
max_meeting = db.query(Meeting).filter(Meeting.milestone_id == milestone.id).order_by(Meeting.id.desc()).first()
|
||||||
|
next_num = (max_meeting.id + 1) if max_meeting else 1
|
||||||
|
meeting_code = f"{milestone_code}:M{next_num:05x}"
|
||||||
|
|
||||||
|
scheduled_at = None
|
||||||
|
if body.meeting_time:
|
||||||
|
try:
|
||||||
|
scheduled_at = datetime.fromisoformat(body.meeting_time.replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid meeting_time format")
|
||||||
|
|
||||||
|
meeting = Meeting(
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
status=MeetingStatus.SCHEDULED,
|
||||||
|
priority=MeetingPriority.MEDIUM,
|
||||||
|
project_id=project_id,
|
||||||
|
milestone_id=milestone.id,
|
||||||
|
reporter_id=current_user.id,
|
||||||
|
meeting_code=meeting_code,
|
||||||
|
scheduled_at=scheduled_at,
|
||||||
|
duration_minutes=body.duration_minutes,
|
||||||
|
)
|
||||||
|
db.add(meeting)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(meeting)
|
||||||
|
|
||||||
|
# Auto-add creator as participant
|
||||||
|
participant = MeetingParticipant(meeting_id=meeting.id, user_id=current_user.id)
|
||||||
|
db.add(participant)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return _serialize_meeting(db, meeting)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meetings")
|
||||||
|
def list_meetings(
|
||||||
|
project: str = None,
|
||||||
|
status_value: str = Query(None, alias="status"),
|
||||||
|
order_by: str = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = db.query(Meeting)
|
||||||
|
|
||||||
|
if project:
|
||||||
|
project_id = _resolve_project_id(db, project)
|
||||||
|
if project_id:
|
||||||
|
query = query.filter(Meeting.project_id == project_id)
|
||||||
|
|
||||||
|
if status_value:
|
||||||
|
query = query.filter(Meeting.status == status_value)
|
||||||
|
|
||||||
|
sort_fields = {
|
||||||
|
"created": Meeting.created_at,
|
||||||
|
"created_at": Meeting.created_at,
|
||||||
|
"due-date": Meeting.scheduled_at,
|
||||||
|
"scheduled_at": Meeting.scheduled_at,
|
||||||
|
"name": Meeting.title,
|
||||||
|
"title": Meeting.title,
|
||||||
|
}
|
||||||
|
sort_col = sort_fields.get(order_by, Meeting.created_at)
|
||||||
|
query = query.order_by(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": [_serialize_meeting(db, m) for m in items],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meetings/{meeting_id}")
|
||||||
|
def get_meeting(meeting_id: str, db: Session = Depends(get_db)):
|
||||||
|
meeting = _find_meeting_by_id_or_code(db, meeting_id)
|
||||||
|
if not meeting:
|
||||||
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
|
return _serialize_meeting(db, meeting)
|
||||||
|
|
||||||
|
|
||||||
|
class MeetingUpdateBody(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
meeting_time: Optional[str] = None
|
||||||
|
duration_minutes: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/meetings/{meeting_id}")
|
||||||
|
def update_meeting(
|
||||||
|
meeting_id: str,
|
||||||
|
body: MeetingUpdateBody,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
meeting = _find_meeting_by_id_or_code(db, meeting_id)
|
||||||
|
if not meeting:
|
||||||
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
|
check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
|
||||||
|
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
if not update_data:
|
||||||
|
raise HTTPException(status_code=400, detail="No supported fields to update")
|
||||||
|
|
||||||
|
if "title" in update_data and update_data["title"] is not None:
|
||||||
|
meeting.title = update_data["title"]
|
||||||
|
if "description" in update_data:
|
||||||
|
meeting.description = update_data["description"]
|
||||||
|
if "status" in update_data and update_data["status"] is not None:
|
||||||
|
meeting.status = MeetingStatus(update_data["status"])
|
||||||
|
if "meeting_time" in update_data and update_data["meeting_time"] is not None:
|
||||||
|
try:
|
||||||
|
meeting.scheduled_at = datetime.fromisoformat(update_data["meeting_time"].replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid meeting_time format")
|
||||||
|
if "duration_minutes" in update_data:
|
||||||
|
meeting.duration_minutes = update_data["duration_minutes"]
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(meeting)
|
||||||
|
return _serialize_meeting(db, meeting)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/meetings/{meeting_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_meeting(
|
||||||
|
meeting_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
meeting = _find_meeting_by_id_or_code(db, meeting_id)
|
||||||
|
if not meeting:
|
||||||
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
|
check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
|
||||||
|
db.delete(meeting)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Attend ----
|
||||||
|
|
||||||
|
@router.post("/meetings/{meeting_id}/attend")
|
||||||
|
def attend_meeting(
|
||||||
|
meeting_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
meeting = _find_meeting_by_id_or_code(db, meeting_id)
|
||||||
|
if not meeting:
|
||||||
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
|
check_project_role(db, current_user.id, meeting.project_id, min_role="viewer")
|
||||||
|
|
||||||
|
existing = db.query(MeetingParticipant).filter(
|
||||||
|
MeetingParticipant.meeting_id == meeting.id,
|
||||||
|
MeetingParticipant.user_id == current_user.id,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return _serialize_meeting(db, meeting)
|
||||||
|
|
||||||
|
participant = MeetingParticipant(meeting_id=meeting.id, user_id=current_user.id)
|
||||||
|
db.add(participant)
|
||||||
|
db.commit()
|
||||||
|
return _serialize_meeting(db, meeting)
|
||||||
@@ -18,8 +18,38 @@ from app.schemas import schemas
|
|||||||
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
|
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
|
||||||
|
|
||||||
|
|
||||||
|
def _find_project(db, identifier) -> models.Project | None:
|
||||||
|
"""Look up project by numeric id or project_code."""
|
||||||
|
try:
|
||||||
|
pid = int(identifier)
|
||||||
|
p = db.query(models.Project).filter(models.Project.id == pid).first()
|
||||||
|
if p:
|
||||||
|
return p
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_milestone(db, identifier, project_id: int = None) -> Milestone | None:
|
||||||
|
"""Look up milestone by numeric id or milestone_code."""
|
||||||
|
try:
|
||||||
|
mid = int(identifier)
|
||||||
|
q = db.query(Milestone).filter(Milestone.id == mid)
|
||||||
|
if project_id:
|
||||||
|
q = q.filter(Milestone.project_id == project_id)
|
||||||
|
ms = q.first()
|
||||||
|
if ms:
|
||||||
|
return ms
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
q = db.query(Milestone).filter(Milestone.milestone_code == str(identifier))
|
||||||
|
if project_id:
|
||||||
|
q = q.filter(Milestone.project_id == project_id)
|
||||||
|
return q.first()
|
||||||
|
|
||||||
|
|
||||||
def _serialize_milestone(milestone):
|
def _serialize_milestone(milestone):
|
||||||
"""Serialize milestone with JSON fields."""
|
"""Serialize milestone with JSON fields and code."""
|
||||||
return {
|
return {
|
||||||
"id": milestone.id,
|
"id": milestone.id,
|
||||||
"title": milestone.title,
|
"title": milestone.title,
|
||||||
@@ -30,6 +60,8 @@ def _serialize_milestone(milestone):
|
|||||||
"depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [],
|
"depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [],
|
||||||
"depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [],
|
"depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [],
|
||||||
"project_id": milestone.project_id,
|
"project_id": milestone.project_id,
|
||||||
|
"milestone_code": milestone.milestone_code,
|
||||||
|
"code": milestone.milestone_code,
|
||||||
"created_by_id": milestone.created_by_id,
|
"created_by_id": milestone.created_by_id,
|
||||||
"started_at": milestone.started_at,
|
"started_at": milestone.started_at,
|
||||||
"created_at": milestone.created_at,
|
"created_at": milestone.created_at,
|
||||||
@@ -38,19 +70,24 @@ def _serialize_milestone(milestone):
|
|||||||
|
|
||||||
|
|
||||||
@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: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="viewer")
|
project = _find_project(db, project_id)
|
||||||
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
|
milestones = db.query(Milestone).filter(Milestone.project_id == project.id).all()
|
||||||
return [_serialize_milestone(m) for m in milestones]
|
return [_serialize_milestone(m) for m in milestones]
|
||||||
|
|
||||||
|
|
||||||
@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: str, milestone: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
project = _find_project(db, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
check_project_role(db, current_user.id, project.id, min_role="mgr")
|
||||||
|
|
||||||
project = db.query(models.Project).filter(models.Project.id == project_id).first()
|
project_code = project.project_code if project.project_code 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()
|
|
||||||
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}"
|
||||||
|
|
||||||
@@ -60,7 +97,7 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se
|
|||||||
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"):
|
||||||
data["depend_on_tasks"] = json.dumps(data["depend_on_tasks"])
|
data["depend_on_tasks"] = json.dumps(data["depend_on_tasks"])
|
||||||
db_milestone = Milestone(project_id=project_id, milestone_code=milestone_code, created_by_id=current_user.id, **data)
|
db_milestone = Milestone(project_id=project.id, milestone_code=milestone_code, created_by_id=current_user.id, **data)
|
||||||
db.add(db_milestone)
|
db.add(db_milestone)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_milestone)
|
db.refresh(db_milestone)
|
||||||
@@ -68,17 +105,23 @@ 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: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="viewer")
|
project = _find_project(db, project_id)
|
||||||
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
|
milestone = _find_milestone(db, milestone_id, project.id)
|
||||||
if not milestone:
|
if not milestone:
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
return _serialize_milestone(milestone)
|
return _serialize_milestone(milestone)
|
||||||
|
|
||||||
|
|
||||||
@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: str, milestone_id: str, milestone: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
|
project = _find_project(db, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
db_milestone = _find_milestone(db, milestone_id, project.id)
|
||||||
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")
|
||||||
ensure_can_edit_milestone(db, current_user.id, db_milestone)
|
ensure_can_edit_milestone(db, current_user.id, db_milestone)
|
||||||
@@ -124,9 +167,12 @@ 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: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="admin")
|
project = _find_project(db, project_id)
|
||||||
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
check_project_role(db, current_user.id, project.id, min_role="admin")
|
||||||
|
db_milestone = _find_milestone(db, milestone_id, project.id)
|
||||||
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")
|
||||||
ms_status = db_milestone.status.value if hasattr(db_milestone.status, 'value') else db_milestone.status
|
ms_status = db_milestone.status.value if hasattr(db_milestone.status, 'value') else db_milestone.status
|
||||||
@@ -138,9 +184,12 @@ def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(g
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
|
@router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
|
||||||
def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def create_milestone_task(project_id: str, milestone_id: str, task_data: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="dev")
|
project = _find_project(db, project_id)
|
||||||
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
check_project_role(db, current_user.id, project.id, min_role="dev")
|
||||||
|
milestone = _find_milestone(db, milestone_id, project.id)
|
||||||
if not milestone:
|
if not milestone:
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
|
||||||
@@ -177,8 +226,8 @@ def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas
|
|||||||
task_subtype=data.get("task_subtype"),
|
task_subtype=data.get("task_subtype"),
|
||||||
status=TaskStatus.PENDING,
|
status=TaskStatus.PENDING,
|
||||||
priority=TaskPriority.MEDIUM,
|
priority=TaskPriority.MEDIUM,
|
||||||
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,
|
||||||
task_code=task_code,
|
task_code=task_code,
|
||||||
estimated_effort=data.get("estimated_effort"),
|
estimated_effort=data.get("estimated_effort"),
|
||||||
@@ -192,15 +241,18 @@ def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas
|
|||||||
|
|
||||||
|
|
||||||
@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: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="viewer")
|
project = _find_project(db, project_id)
|
||||||
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
|
milestone = _find_milestone(db, milestone_id, project.id)
|
||||||
if not milestone:
|
if not milestone:
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
|
||||||
tasks = db.query(Task).filter(Task.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()
|
supports = db.query(Support).filter(Support.milestone_id == milestone.id).all()
|
||||||
meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).all()
|
meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone.id).all()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tasks": [{
|
"tasks": [{
|
||||||
@@ -221,13 +273,16 @@ def get_milestone_items(project_id: int, milestone_id: int, db: Session = Depend
|
|||||||
|
|
||||||
|
|
||||||
@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: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="viewer")
|
project = _find_project(db, project_id)
|
||||||
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
|
milestone = _find_milestone(db, milestone_id, project.id)
|
||||||
if not milestone:
|
if not milestone:
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
|
||||||
all_tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
|
all_tasks = db.query(Task).filter(Task.milestone_id == milestone.id).all()
|
||||||
total = len(all_tasks)
|
total = len(all_tasks)
|
||||||
completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED)
|
completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED)
|
||||||
progress_pct = (completed / total * 100) if total > 0 else 0
|
progress_pct = (completed / total * 100) if total > 0 else 0
|
||||||
@@ -241,7 +296,8 @@ def get_milestone_progress(project_id: int, milestone_id: int, db: Session = Dep
|
|||||||
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
|
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"milestone_id": milestone_id,
|
"milestone_id": milestone.id,
|
||||||
|
"milestone_code": milestone.milestone_code,
|
||||||
"title": milestone.title,
|
"title": milestone.title,
|
||||||
"total": total,
|
"total": total,
|
||||||
"total_tasks": total,
|
"total_tasks": total,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
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 ensure_can_edit_milestone
|
from app.api.rbac import check_project_role, ensure_can_edit_milestone
|
||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.apikey import APIKey
|
from app.models.apikey import APIKey
|
||||||
from app.models.activity import ActivityLog
|
from app.models.activity import ActivityLog
|
||||||
@@ -28,6 +28,19 @@ from app.schemas import schemas
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_milestone(db: Session, identifier: str) -> MilestoneModel:
|
||||||
|
"""Resolve a milestone by numeric id or milestone_code string.
|
||||||
|
Raises 404 if not found."""
|
||||||
|
try:
|
||||||
|
ms_id = int(identifier)
|
||||||
|
ms = db.query(MilestoneModel).filter(MilestoneModel.id == ms_id).first()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == identifier).first()
|
||||||
|
if not ms:
|
||||||
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
return ms
|
||||||
|
|
||||||
|
|
||||||
# ============ API Keys ============
|
# ============ API Keys ============
|
||||||
|
|
||||||
class APIKeyCreate(BaseModel):
|
class APIKeyCreate(BaseModel):
|
||||||
@@ -136,28 +149,46 @@ def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db),
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"])
|
@router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"])
|
||||||
def list_milestones(project_id: int = None, status_filter: str = None, db: Session = Depends(get_db)):
|
def list_milestones(project_id: str = None, status_filter: str = None, db: Session = Depends(get_db)):
|
||||||
query = db.query(MilestoneModel)
|
query = db.query(MilestoneModel)
|
||||||
if project_id:
|
if project_id:
|
||||||
query = query.filter(MilestoneModel.project_id == project_id)
|
# Resolve project_id by numeric id or project_code
|
||||||
|
resolved_project = None
|
||||||
|
try:
|
||||||
|
pid = int(project_id)
|
||||||
|
resolved_project = db.query(models.Project).filter(models.Project.id == pid).first()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if not resolved_project:
|
||||||
|
resolved_project = db.query(models.Project).filter(models.Project.project_code == project_id).first()
|
||||||
|
if not resolved_project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
query = query.filter(MilestoneModel.project_id == resolved_project.id)
|
||||||
if status_filter:
|
if status_filter:
|
||||||
query = query.filter(MilestoneModel.status == status_filter)
|
query = query.filter(MilestoneModel.status == status_filter)
|
||||||
return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all()
|
return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_milestone_by_id_or_code(db, identifier) -> MilestoneModel | None:
|
||||||
|
"""Look up milestone by numeric id or milestone_code."""
|
||||||
|
try:
|
||||||
|
mid = int(identifier)
|
||||||
|
ms = db.query(MilestoneModel).filter(MilestoneModel.id == mid).first()
|
||||||
|
if ms:
|
||||||
|
return ms
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return db.query(MilestoneModel).filter(MilestoneModel.milestone_code == str(identifier)).first()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
|
@router.get("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
|
||||||
def get_milestone(milestone_id: int, db: Session = Depends(get_db)):
|
def get_milestone(milestone_id: str, db: Session = Depends(get_db)):
|
||||||
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
|
return _resolve_milestone(db, milestone_id)
|
||||||
if not ms:
|
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
|
||||||
return ms
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
|
@router.patch("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
|
||||||
def update_milestone(milestone_id: int, ms_update: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def update_milestone(milestone_id: str, ms_update: schemas.MilestoneUpdate, 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()
|
ms = _resolve_milestone(db, milestone_id)
|
||||||
if not ms:
|
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
|
||||||
ensure_can_edit_milestone(db, current_user.id, ms)
|
ensure_can_edit_milestone(db, current_user.id, ms)
|
||||||
for field, value in ms_update.model_dump(exclude_unset=True).items():
|
for field, value in ms_update.model_dump(exclude_unset=True).items():
|
||||||
setattr(ms, field, value)
|
setattr(ms, field, value)
|
||||||
@@ -167,21 +198,17 @@ def update_milestone(milestone_id: int, ms_update: schemas.MilestoneUpdate, db:
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"])
|
@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"])
|
||||||
def delete_milestone(milestone_id: int, db: Session = Depends(get_db)):
|
def delete_milestone(milestone_id: str, db: Session = Depends(get_db)):
|
||||||
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
|
ms = _resolve_milestone(db, milestone_id)
|
||||||
if not ms:
|
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
|
||||||
db.delete(ms)
|
db.delete(ms)
|
||||||
db.commit()
|
db.commit()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@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: str, db: Session = Depends(get_db)):
|
||||||
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
|
ms = _resolve_milestone(db, milestone_id)
|
||||||
if not ms:
|
tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
|
||||||
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
|
|
||||||
total = len(tasks)
|
total = len(tasks)
|
||||||
done = sum(1 for t in tasks if t.status == TaskStatus.CLOSED)
|
done = sum(1 for t in tasks if t.status == TaskStatus.CLOSED)
|
||||||
|
|
||||||
@@ -193,7 +220,7 @@ def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
|
|||||||
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
|
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"milestone_id": milestone_id,
|
"milestone_id": ms.id,
|
||||||
"title": ms.title,
|
"title": ms.title,
|
||||||
"total": total,
|
"total": total,
|
||||||
"total_tasks": total,
|
"total_tasks": total,
|
||||||
@@ -311,18 +338,34 @@ def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"])
|
@router.get("/tasks/{task_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"])
|
||||||
def list_task_worklogs(task_id: int, db: Session = Depends(get_db)):
|
def list_task_worklogs(task_id: str, db: Session = Depends(get_db)):
|
||||||
return db.query(WorkLog).filter(WorkLog.task_id == task_id).order_by(WorkLog.logged_date.desc()).all()
|
"""List worklogs for a task. task_id can be numeric id or task_code."""
|
||||||
|
try:
|
||||||
|
tid = int(task_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
task = db.query(Task).filter(Task.task_code == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
tid = task.id
|
||||||
|
return db.query(WorkLog).filter(WorkLog.task_id == tid).order_by(WorkLog.logged_date.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}/worklogs/summary", tags=["Time Tracking"])
|
@router.get("/tasks/{task_id}/worklogs/summary", tags=["Time Tracking"])
|
||||||
def task_worklog_summary(task_id: int, db: Session = Depends(get_db)):
|
def task_worklog_summary(task_id: str, db: Session = Depends(get_db)):
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
"""Worklog summary for a task. task_id can be numeric id or task_code."""
|
||||||
|
try:
|
||||||
|
tid = int(task_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
t = db.query(Task).filter(Task.task_code == task_id).first()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
tid = t.id
|
||||||
|
task = db.query(Task).filter(Task.id == tid).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == task_id).scalar() or 0
|
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == tid).scalar() or 0
|
||||||
count = db.query(WorkLog).filter(WorkLog.task_id == task_id).count()
|
count = db.query(WorkLog).filter(WorkLog.task_id == tid).count()
|
||||||
return {"task_id": task_id, "total_hours": round(total, 2), "log_count": count}
|
return {"task_id": tid, "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"])
|
||||||
@@ -472,26 +515,87 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
|
|||||||
|
|
||||||
# ============ Supports ============
|
# ============ Supports ============
|
||||||
|
|
||||||
|
|
||||||
|
def _find_support_by_id_or_code(db: Session, identifier: str) -> Support | None:
|
||||||
|
try:
|
||||||
|
support_id = int(identifier)
|
||||||
|
support = db.query(Support).filter(Support.id == support_id).first()
|
||||||
|
if support:
|
||||||
|
return support
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return db.query(Support).filter(Support.support_code == str(identifier)).first()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_support(db: Session, support: Support) -> dict:
|
||||||
|
project = db.query(models.Project).filter(models.Project.id == support.project_id).first()
|
||||||
|
milestone = db.query(MilestoneModel).filter(MilestoneModel.id == support.milestone_id).first()
|
||||||
|
assignee = None
|
||||||
|
if support.assignee_id:
|
||||||
|
assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": support.id,
|
||||||
|
"code": support.support_code,
|
||||||
|
"support_code": support.support_code,
|
||||||
|
"title": support.title,
|
||||||
|
"description": support.description,
|
||||||
|
"status": support.status.value if hasattr(support.status, "value") else support.status,
|
||||||
|
"priority": support.priority.value if hasattr(support.priority, "value") else support.priority,
|
||||||
|
"project_id": support.project_id,
|
||||||
|
"project_code": project.project_code if project else None,
|
||||||
|
"milestone_id": support.milestone_id,
|
||||||
|
"milestone_code": milestone.milestone_code if milestone else None,
|
||||||
|
"reporter_id": support.reporter_id,
|
||||||
|
"assignee_id": support.assignee_id,
|
||||||
|
"taken_by": assignee.username if assignee else None,
|
||||||
|
"created_at": support.created_at,
|
||||||
|
"updated_at": support.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/supports", tags=["Supports"])
|
||||||
|
def list_all_supports(
|
||||||
|
status: str | None = None,
|
||||||
|
taken_by: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
"""List support tickets across all projects. Optional status/taken_by filters."""
|
||||||
|
query = db.query(Support)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(Support.status == SupportStatus(status))
|
||||||
|
|
||||||
|
if taken_by == "me":
|
||||||
|
query = query.filter(Support.assignee_id == current_user.id)
|
||||||
|
elif taken_by == "null":
|
||||||
|
query = query.filter(Support.assignee_id.is_(None))
|
||||||
|
elif taken_by:
|
||||||
|
assignee = db.query(models.User).filter(models.User.username == taken_by).first()
|
||||||
|
if assignee:
|
||||||
|
query = query.filter(Support.assignee_id == assignee.id)
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
query = query.order_by(Support.created_at.desc())
|
||||||
|
supports = query.all()
|
||||||
|
return [_serialize_support(db, s) for s in supports]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
|
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
|
||||||
def list_supports(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
|
def list_supports(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")
|
||||||
|
|
||||||
supports = db.query(Support).filter(
|
supports = db.query(Support).filter(
|
||||||
Support.project_id == project.id,
|
Support.project_id == project.id,
|
||||||
Support.milestone_id == milestone_id
|
Support.milestone_id == milestone_id
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
return [{
|
return [_serialize_support(db, s) for s in supports]
|
||||||
"id": s.id,
|
|
||||||
"title": s.title,
|
|
||||||
"description": s.description,
|
|
||||||
"status": s.status.value,
|
|
||||||
"priority": s.priority.value,
|
|
||||||
"assignee_id": s.assignee_id,
|
|
||||||
"created_at": s.created_at,
|
|
||||||
} for s in supports]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"])
|
@router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"])
|
||||||
@@ -499,19 +603,19 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, 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")
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
if ms.status and hasattr(ms.status, "value") and ms.status.value == "undergoing":
|
if ms.status and hasattr(ms.status, "value") and ms.status.value == "undergoing":
|
||||||
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
|
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
|
||||||
|
|
||||||
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
|
||||||
support_code = f"{milestone_code}:S{next_num:05x}"
|
support_code = f"{milestone_code}:S{next_num:05x}"
|
||||||
|
|
||||||
support = Support(
|
support = Support(
|
||||||
title=support_data.get("title"),
|
title=support_data.get("title"),
|
||||||
description=support_data.get("description"),
|
description=support_data.get("description"),
|
||||||
@@ -525,7 +629,89 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
|
|||||||
db.add(support)
|
db.add(support)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(support)
|
db.refresh(support)
|
||||||
return support
|
return _serialize_support(db, support)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/supports/{support_id}", tags=["Supports"])
|
||||||
|
def get_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
|
support = _find_support_by_id_or_code(db, support_id)
|
||||||
|
if not support:
|
||||||
|
raise HTTPException(status_code=404, detail="Support not found")
|
||||||
|
check_project_role(db, current_user.id, support.project_id, min_role="viewer")
|
||||||
|
return _serialize_support(db, support)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/supports/{support_id}", tags=["Supports"])
|
||||||
|
def update_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
|
support = _find_support_by_id_or_code(db, support_id)
|
||||||
|
if not support:
|
||||||
|
raise HTTPException(status_code=404, detail="Support not found")
|
||||||
|
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
||||||
|
|
||||||
|
allowed_fields = {"title", "description", "status", "priority"}
|
||||||
|
updated = False
|
||||||
|
for field, value in support_data.items():
|
||||||
|
if field not in allowed_fields:
|
||||||
|
continue
|
||||||
|
if field == "status" and value is not None:
|
||||||
|
value = SupportStatus(value)
|
||||||
|
if field == "priority" and value is not None:
|
||||||
|
value = SupportPriority(value)
|
||||||
|
setattr(support, field, value)
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=400, detail="No supported fields to update")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(support)
|
||||||
|
return _serialize_support(db, support)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/supports/{support_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"])
|
||||||
|
def delete_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
|
support = _find_support_by_id_or_code(db, support_id)
|
||||||
|
if not support:
|
||||||
|
raise HTTPException(status_code=404, detail="Support not found")
|
||||||
|
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
||||||
|
db.delete(support)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/supports/{support_id}/take", tags=["Supports"])
|
||||||
|
def take_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
|
support = _find_support_by_id_or_code(db, support_id)
|
||||||
|
if not support:
|
||||||
|
raise HTTPException(status_code=404, detail="Support not found")
|
||||||
|
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
||||||
|
|
||||||
|
if support.assignee_id and support.assignee_id != current_user.id:
|
||||||
|
assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first()
|
||||||
|
assignee_name = assignee.username if assignee else str(support.assignee_id)
|
||||||
|
raise HTTPException(status_code=409, detail=f"Support is already taken by {assignee_name}")
|
||||||
|
|
||||||
|
support.assignee_id = current_user.id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(support)
|
||||||
|
return _serialize_support(db, support)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/supports/{support_id}/transition", tags=["Supports"])
|
||||||
|
def transition_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
|
support = _find_support_by_id_or_code(db, support_id)
|
||||||
|
if not support:
|
||||||
|
raise HTTPException(status_code=404, detail="Support not found")
|
||||||
|
check_project_role(db, current_user.id, support.project_id, min_role="dev")
|
||||||
|
|
||||||
|
status_value = support_data.get("status")
|
||||||
|
if not status_value:
|
||||||
|
raise HTTPException(status_code=400, detail="status is required")
|
||||||
|
|
||||||
|
support.status = SupportStatus(status_value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(support)
|
||||||
|
return _serialize_support(db, support)
|
||||||
|
|
||||||
|
|
||||||
# ============ Meetings ============
|
# ============ Meetings ============
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ from app.api.rbac import check_project_role, check_permission, ensure_can_edit_p
|
|||||||
router = APIRouter(prefix="/projects", tags=["Projects"])
|
router = APIRouter(prefix="/projects", tags=["Projects"])
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_project(db: Session, identifier: str) -> models.Project:
|
||||||
|
"""Resolve a project by numeric id or project_code string.
|
||||||
|
Raises 404 if not found."""
|
||||||
|
try:
|
||||||
|
pid = int(identifier)
|
||||||
|
project = db.query(models.Project).filter(models.Project.id == pid).first()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
project = db.query(models.Project).filter(models.Project.project_code == identifier).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
def _validate_project_links(db, codes: list[str] | None, self_code: str | None = None) -> list[str] | None:
|
def _validate_project_links(db, codes: list[str] | None, self_code: str | None = None) -> list[str] | None:
|
||||||
if not codes:
|
if not codes:
|
||||||
return None
|
return None
|
||||||
@@ -182,24 +195,31 @@ def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)
|
|||||||
return db.query(models.Project).offset(skip).limit(limit).all()
|
return db.query(models.Project).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_project_by_id_or_code(db, identifier) -> models.Project | None:
|
||||||
|
"""Look up project by numeric id or project_code."""
|
||||||
|
try:
|
||||||
|
pid = int(identifier)
|
||||||
|
project = db.query(models.Project).filter(models.Project.id == pid).first()
|
||||||
|
if project:
|
||||||
|
return project
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}", response_model=schemas.ProjectResponse)
|
@router.get("/{project_id}", response_model=schemas.ProjectResponse)
|
||||||
def get_project(project_id: int, db: Session = Depends(get_db)):
|
def get_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
project = db.query(models.Project).filter(models.Project.id == project_id).first()
|
return _resolve_project(db, project_id)
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
return project
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{project_id}", response_model=schemas.ProjectResponse)
|
@router.patch("/{project_id}", response_model=schemas.ProjectResponse)
|
||||||
def update_project(
|
def update_project(
|
||||||
project_id: int,
|
project_id: str,
|
||||||
project_update: schemas.ProjectUpdate,
|
project_update: schemas.ProjectUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
project = db.query(models.Project).filter(models.Project.id == project_id).first()
|
project = _resolve_project(db, project_id)
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
ensure_can_edit_project(db, current_user.id, project)
|
ensure_can_edit_project(db, current_user.id, project)
|
||||||
update_data = project_update.model_dump(exclude_unset=True)
|
update_data = project_update.model_dump(exclude_unset=True)
|
||||||
update_data.pop("name", None)
|
update_data.pop("name", None)
|
||||||
@@ -220,21 +240,20 @@ def update_project(
|
|||||||
|
|
||||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_project(
|
def delete_project(
|
||||||
project_id: int,
|
project_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="admin")
|
project = _resolve_project(db, project_id)
|
||||||
project = db.query(models.Project).filter(models.Project.id == project_id).first()
|
check_project_role(db, current_user.id, project.id, min_role="admin")
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
|
|
||||||
project_code = project.project_code
|
project_code = project.project_code
|
||||||
|
project_id_val = project.id
|
||||||
|
|
||||||
# Delete milestones and their tasks
|
# Delete milestones and their tasks
|
||||||
from app.models.milestone import Milestone
|
from app.models.milestone import Milestone
|
||||||
from app.models.task import Task
|
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_val).all()
|
||||||
for ms in milestones:
|
for ms in milestones:
|
||||||
tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
|
tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
@@ -242,7 +261,7 @@ def delete_project(
|
|||||||
db.delete(ms)
|
db.delete(ms)
|
||||||
|
|
||||||
# Delete project members
|
# Delete project members
|
||||||
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all()
|
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project.id).all()
|
||||||
for m in members:
|
for m in members:
|
||||||
db.delete(m)
|
db.delete(m)
|
||||||
|
|
||||||
@@ -269,27 +288,25 @@ def delete_project(
|
|||||||
|
|
||||||
@router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def add_project_member(
|
def add_project_member(
|
||||||
project_id: int,
|
project_id: str,
|
||||||
member: schemas.ProjectMemberCreate,
|
member: schemas.ProjectMemberCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
project = _resolve_project(db, project_id)
|
||||||
project = db.query(models.Project).filter(models.Project.id == project_id).first()
|
check_project_role(db, current_user.id, project.id, min_role="mgr")
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
|
||||||
user = db.query(models.User).filter(models.User.id == member.user_id).first()
|
user = db.query(models.User).filter(models.User.id == member.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")
|
||||||
existing = db.query(models.ProjectMember).filter(
|
existing = db.query(models.ProjectMember).filter(
|
||||||
models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == member.user_id
|
models.ProjectMember.project_id == project.id, models.ProjectMember.user_id == member.user_id
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="User already a member")
|
raise HTTPException(status_code=400, detail="User already a member")
|
||||||
# Convert role name to role_id
|
# Convert role name to role_id
|
||||||
role = db.query(Role).filter(Role.name == member.role).first()
|
role = db.query(Role).filter(Role.name == member.role).first()
|
||||||
role_id = role.id if role else None
|
role_id = role.id if role else None
|
||||||
db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role_id=role_id)
|
db_member = models.ProjectMember(project_id=project.id, user_id=member.user_id, role_id=role_id)
|
||||||
db.add(db_member)
|
db.add(db_member)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_member)
|
db.refresh(db_member)
|
||||||
@@ -307,8 +324,9 @@ def add_project_member(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse])
|
@router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse])
|
||||||
def list_project_members(project_id: int, db: Session = Depends(get_db)):
|
def list_project_members(project_id: str, db: Session = Depends(get_db)):
|
||||||
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all()
|
project = _resolve_project(db, project_id)
|
||||||
|
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project.id).all()
|
||||||
result = []
|
result = []
|
||||||
for m in members:
|
for m in members:
|
||||||
role_name = "developer"
|
role_name = "developer"
|
||||||
@@ -316,9 +334,12 @@ def list_project_members(project_id: int, db: Session = Depends(get_db)):
|
|||||||
role = db.query(Role).filter(Role.id == m.role_id).first()
|
role = db.query(Role).filter(Role.id == m.role_id).first()
|
||||||
if role:
|
if role:
|
||||||
role_name = role.name
|
role_name = role.name
|
||||||
|
user = db.query(models.User).filter(models.User.id == m.user_id).first()
|
||||||
result.append({
|
result.append({
|
||||||
"id": m.id,
|
"id": m.id,
|
||||||
"user_id": m.user_id,
|
"user_id": m.user_id,
|
||||||
|
"username": user.username if user else None,
|
||||||
|
"full_name": user.full_name if user else None,
|
||||||
"project_id": m.project_id,
|
"project_id": m.project_id,
|
||||||
"role": role_name
|
"role": role_name
|
||||||
})
|
})
|
||||||
@@ -327,14 +348,15 @@ def list_project_members(project_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def remove_project_member(
|
def remove_project_member(
|
||||||
project_id: int,
|
project_id: str,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
check_permission(db, current_user.id, project_id, "member.remove")
|
project = _resolve_project(db, project_id)
|
||||||
|
check_permission(db, current_user.id, project.id, "member.remove")
|
||||||
member = db.query(models.ProjectMember).filter(
|
member = db.query(models.ProjectMember).filter(
|
||||||
models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id
|
models.ProjectMember.project_id == project.id, models.ProjectMember.user_id == user_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# Prevent removing project owner (admin role)
|
# Prevent removing project owner (admin role)
|
||||||
@@ -362,16 +384,18 @@ 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: str, db: Session = Depends(get_db)):
|
||||||
from app.models.task import Task as TaskModel
|
from app.models.task import Task as TaskModel
|
||||||
|
project = _resolve_project(db, project_id)
|
||||||
|
resolved_project_id = project.id
|
||||||
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(TaskModel, WorkLog.task_id == TaskModel.id)\
|
.join(TaskModel, WorkLog.task_id == TaskModel.id)\
|
||||||
.filter(TaskModel.project_id == project_id)\
|
.filter(TaskModel.project_id == resolved_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]
|
||||||
return {"project_id": project_id, "total_hours": round(total, 2), "by_user": by_user}
|
return {"project_id": resolved_project_id, "total_hours": round(total, 2), "by_user": by_user}
|
||||||
|
|||||||
@@ -17,6 +17,54 @@ from app.services.activity import log_activity
|
|||||||
router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes"])
|
router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes"])
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_propose(db: Session, propose: Propose) -> dict:
|
||||||
|
"""Serialize propose with created_by_username."""
|
||||||
|
creator = db.query(models.User).filter(models.User.id == propose.created_by_id).first() if propose.created_by_id else None
|
||||||
|
return {
|
||||||
|
"id": propose.id,
|
||||||
|
"title": propose.title,
|
||||||
|
"description": propose.description,
|
||||||
|
"propose_code": propose.propose_code,
|
||||||
|
"status": propose.status.value if hasattr(propose.status, "value") else propose.status,
|
||||||
|
"project_id": propose.project_id,
|
||||||
|
"created_by_id": propose.created_by_id,
|
||||||
|
"created_by_username": creator.username if creator else None,
|
||||||
|
"feat_task_id": propose.feat_task_id,
|
||||||
|
"created_at": propose.created_at,
|
||||||
|
"updated_at": propose.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_project(db, identifier):
|
||||||
|
"""Look up project by numeric id or project_code."""
|
||||||
|
try:
|
||||||
|
pid = int(identifier)
|
||||||
|
p = db.query(models.Project).filter(models.Project.id == pid).first()
|
||||||
|
if p:
|
||||||
|
return p
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_propose(db, identifier, project_id: int = None) -> Propose | None:
|
||||||
|
"""Look up propose by numeric id or propose_code."""
|
||||||
|
try:
|
||||||
|
pid = int(identifier)
|
||||||
|
q = db.query(Propose).filter(Propose.id == pid)
|
||||||
|
if project_id:
|
||||||
|
q = q.filter(Propose.project_id == project_id)
|
||||||
|
p = q.first()
|
||||||
|
if p:
|
||||||
|
return p
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
q = db.query(Propose).filter(Propose.propose_code == str(identifier))
|
||||||
|
if project_id:
|
||||||
|
q = q.filter(Propose.project_id == project_id)
|
||||||
|
return q.first()
|
||||||
|
|
||||||
|
|
||||||
def _generate_propose_code(db: Session, project_id: int) -> str:
|
def _generate_propose_code(db: Session, project_id: int) -> str:
|
||||||
"""Generate next propose code: {proj_code}:P{i:05x}"""
|
"""Generate next propose code: {proj_code}:P{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()
|
||||||
@@ -48,36 +96,42 @@ def _can_edit_propose(db: Session, user_id: int, propose: Propose) -> bool:
|
|||||||
|
|
||||||
@router.get("", response_model=List[schemas.ProposeResponse])
|
@router.get("", response_model=List[schemas.ProposeResponse])
|
||||||
def list_proposes(
|
def list_proposes(
|
||||||
project_id: int,
|
project_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="viewer")
|
project = _find_project(db, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
proposes = (
|
proposes = (
|
||||||
db.query(Propose)
|
db.query(Propose)
|
||||||
.filter(Propose.project_id == project_id)
|
.filter(Propose.project_id == project.id)
|
||||||
.order_by(Propose.id.desc())
|
.order_by(Propose.id.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return proposes
|
return [_serialize_propose(db, p) for p in proposes]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_propose(
|
def create_propose(
|
||||||
project_id: int,
|
project_id: str,
|
||||||
propose_in: schemas.ProposeCreate,
|
propose_in: schemas.ProposeCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="dev")
|
project = _find_project(db, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
check_project_role(db, current_user.id, project.id, min_role="dev")
|
||||||
|
|
||||||
propose_code = _generate_propose_code(db, project_id)
|
propose_code = _generate_propose_code(db, project.id)
|
||||||
|
|
||||||
propose = Propose(
|
propose = Propose(
|
||||||
title=propose_in.title,
|
title=propose_in.title,
|
||||||
description=propose_in.description,
|
description=propose_in.description,
|
||||||
status=ProposeStatus.OPEN,
|
status=ProposeStatus.OPEN,
|
||||||
project_id=project_id,
|
project_id=project.id,
|
||||||
created_by_id=current_user.id,
|
created_by_id=current_user.id,
|
||||||
propose_code=propose_code,
|
propose_code=propose_code,
|
||||||
)
|
)
|
||||||
@@ -87,32 +141,38 @@ def create_propose(
|
|||||||
|
|
||||||
log_activity(db, "create", "propose", propose.id, user_id=current_user.id, details={"title": propose.title})
|
log_activity(db, "create", "propose", propose.id, user_id=current_user.id, details={"title": propose.title})
|
||||||
|
|
||||||
return propose
|
return _serialize_propose(db, propose)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{propose_id}", response_model=schemas.ProposeResponse)
|
@router.get("/{propose_id}", response_model=schemas.ProposeResponse)
|
||||||
def get_propose(
|
def get_propose(
|
||||||
project_id: int,
|
project_id: str,
|
||||||
propose_id: int,
|
propose_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
check_project_role(db, current_user.id, project_id, min_role="viewer")
|
project = _find_project(db, project_id)
|
||||||
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first()
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
check_project_role(db, current_user.id, project.id, min_role="viewer")
|
||||||
|
propose = _find_propose(db, propose_id, project.id)
|
||||||
if not propose:
|
if not propose:
|
||||||
raise HTTPException(status_code=404, detail="Propose not found")
|
raise HTTPException(status_code=404, detail="Propose not found")
|
||||||
return propose
|
return _serialize_propose(db, propose)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{propose_id}", response_model=schemas.ProposeResponse)
|
@router.patch("/{propose_id}", response_model=schemas.ProposeResponse)
|
||||||
def update_propose(
|
def update_propose(
|
||||||
project_id: int,
|
project_id: str,
|
||||||
propose_id: int,
|
propose_id: str,
|
||||||
propose_in: schemas.ProposeUpdate,
|
propose_in: schemas.ProposeUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first()
|
project = _find_project(db, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
propose = _find_propose(db, propose_id, project.id)
|
||||||
if not propose:
|
if not propose:
|
||||||
raise HTTPException(status_code=404, detail="Propose not found")
|
raise HTTPException(status_code=404, detail="Propose not found")
|
||||||
|
|
||||||
@@ -135,7 +195,7 @@ def update_propose(
|
|||||||
|
|
||||||
log_activity(db, "update", "propose", propose.id, user_id=current_user.id, details=data)
|
log_activity(db, "update", "propose", propose.id, user_id=current_user.id, details=data)
|
||||||
|
|
||||||
return propose
|
return _serialize_propose(db, propose)
|
||||||
|
|
||||||
|
|
||||||
# ---- Actions ----
|
# ---- Actions ----
|
||||||
@@ -146,14 +206,17 @@ class AcceptRequest(schemas.BaseModel):
|
|||||||
|
|
||||||
@router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse)
|
@router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse)
|
||||||
def accept_propose(
|
def accept_propose(
|
||||||
project_id: int,
|
project_id: str,
|
||||||
propose_id: int,
|
propose_id: str,
|
||||||
body: AcceptRequest,
|
body: AcceptRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""Accept a propose: create a feature story task in the chosen milestone."""
|
"""Accept a propose: create a feature story task in the chosen milestone."""
|
||||||
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first()
|
project = _find_project(db, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
propose = _find_propose(db, propose_id, project.id)
|
||||||
if not propose:
|
if not propose:
|
||||||
raise HTTPException(status_code=404, detail="Propose not found")
|
raise HTTPException(status_code=404, detail="Propose not found")
|
||||||
|
|
||||||
@@ -161,12 +224,12 @@ def accept_propose(
|
|||||||
if propose_status != "open":
|
if propose_status != "open":
|
||||||
raise HTTPException(status_code=400, detail="Only open proposes can be accepted")
|
raise HTTPException(status_code=400, detail="Only open proposes can be accepted")
|
||||||
|
|
||||||
check_permission(db, current_user.id, project_id, "propose.accept")
|
check_permission(db, current_user.id, project.id, "propose.accept")
|
||||||
|
|
||||||
# Validate milestone
|
# Validate milestone
|
||||||
milestone = db.query(Milestone).filter(
|
milestone = db.query(Milestone).filter(
|
||||||
Milestone.id == body.milestone_id,
|
Milestone.id == body.milestone_id,
|
||||||
Milestone.project_id == project_id,
|
Milestone.project_id == project.id,
|
||||||
).first()
|
).first()
|
||||||
if not milestone:
|
if not milestone:
|
||||||
raise HTTPException(status_code=404, detail="Milestone not found in this project")
|
raise HTTPException(status_code=404, detail="Milestone not found in this project")
|
||||||
@@ -189,7 +252,7 @@ def accept_propose(
|
|||||||
task_subtype="feature",
|
task_subtype="feature",
|
||||||
status=TaskStatus.PENDING,
|
status=TaskStatus.PENDING,
|
||||||
priority=TaskPriority.MEDIUM,
|
priority=TaskPriority.MEDIUM,
|
||||||
project_id=project_id,
|
project_id=project.id,
|
||||||
milestone_id=milestone.id,
|
milestone_id=milestone.id,
|
||||||
reporter_id=propose.created_by_id or current_user.id,
|
reporter_id=propose.created_by_id or current_user.id,
|
||||||
created_by_id=propose.created_by_id or current_user.id,
|
created_by_id=propose.created_by_id or current_user.id,
|
||||||
@@ -211,7 +274,7 @@ def accept_propose(
|
|||||||
"task_code": task_code,
|
"task_code": task_code,
|
||||||
})
|
})
|
||||||
|
|
||||||
return propose
|
return _serialize_propose(db, propose)
|
||||||
|
|
||||||
|
|
||||||
class RejectRequest(schemas.BaseModel):
|
class RejectRequest(schemas.BaseModel):
|
||||||
@@ -220,14 +283,17 @@ class RejectRequest(schemas.BaseModel):
|
|||||||
|
|
||||||
@router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse)
|
@router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse)
|
||||||
def reject_propose(
|
def reject_propose(
|
||||||
project_id: int,
|
project_id: str,
|
||||||
propose_id: int,
|
propose_id: str,
|
||||||
body: RejectRequest | None = None,
|
body: RejectRequest | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""Reject a propose."""
|
"""Reject a propose."""
|
||||||
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first()
|
project = _find_project(db, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
propose = _find_propose(db, propose_id, project.id)
|
||||||
if not propose:
|
if not propose:
|
||||||
raise HTTPException(status_code=404, detail="Propose not found")
|
raise HTTPException(status_code=404, detail="Propose not found")
|
||||||
|
|
||||||
@@ -235,7 +301,7 @@ def reject_propose(
|
|||||||
if propose_status != "open":
|
if propose_status != "open":
|
||||||
raise HTTPException(status_code=400, detail="Only open proposes can be rejected")
|
raise HTTPException(status_code=400, detail="Only open proposes can be rejected")
|
||||||
|
|
||||||
check_permission(db, current_user.id, project_id, "propose.reject")
|
check_permission(db, current_user.id, project.id, "propose.reject")
|
||||||
|
|
||||||
propose.status = ProposeStatus.REJECTED
|
propose.status = ProposeStatus.REJECTED
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -245,18 +311,21 @@ def reject_propose(
|
|||||||
"reason": body.reason if body else None,
|
"reason": body.reason if body else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
return propose
|
return _serialize_propose(db, propose)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse)
|
@router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse)
|
||||||
def reopen_propose(
|
def reopen_propose(
|
||||||
project_id: int,
|
project_id: str,
|
||||||
propose_id: int,
|
propose_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
"""Reopen a rejected propose back to open."""
|
"""Reopen a rejected propose back to open."""
|
||||||
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first()
|
project = _find_project(db, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
propose = _find_propose(db, propose_id, project.id)
|
||||||
if not propose:
|
if not propose:
|
||||||
raise HTTPException(status_code=404, detail="Propose not found")
|
raise HTTPException(status_code=404, detail="Propose not found")
|
||||||
|
|
||||||
@@ -264,7 +333,7 @@ def reopen_propose(
|
|||||||
if propose_status != "rejected":
|
if propose_status != "rejected":
|
||||||
raise HTTPException(status_code=400, detail="Only rejected proposes can be reopened")
|
raise HTTPException(status_code=400, detail="Only rejected proposes can be reopened")
|
||||||
|
|
||||||
check_permission(db, current_user.id, project_id, "propose.reopen")
|
check_permission(db, current_user.id, project.id, "propose.reopen")
|
||||||
|
|
||||||
propose.status = ProposeStatus.OPEN
|
propose.status = ProposeStatus.OPEN
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -272,4 +341,4 @@ def reopen_propose(
|
|||||||
|
|
||||||
log_activity(db, "reopen", "propose", propose.id, user_id=current_user.id)
|
log_activity(db, "reopen", "propose", propose.id, user_id=current_user.id)
|
||||||
|
|
||||||
return propose
|
return _serialize_propose(db, propose)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import math
|
import math
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -20,6 +20,19 @@ from app.services.dependency_check import check_task_deps
|
|||||||
|
|
||||||
router = APIRouter(tags=["Tasks"])
|
router = APIRouter(tags=["Tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_task(db: Session, identifier: str) -> Task:
|
||||||
|
"""Resolve a task by numeric id or task_code string.
|
||||||
|
Raises 404 if not found."""
|
||||||
|
try:
|
||||||
|
task_id = int(identifier)
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
task = db.query(Task).filter(Task.task_code == identifier).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
return task
|
||||||
|
|
||||||
# ---- State-machine: valid transitions (P5.1-P5.6) ----
|
# ---- State-machine: valid transitions (P5.1-P5.6) ----
|
||||||
VALID_TRANSITIONS: dict[str, set[str]] = {
|
VALID_TRANSITIONS: dict[str, set[str]] = {
|
||||||
"pending": {"open", "closed"},
|
"pending": {"open", "closed"},
|
||||||
@@ -88,27 +101,100 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_project_id(db: Session, project_id: int | None, project_code: str | None) -> int | None:
|
||||||
|
if project_id:
|
||||||
|
return project_id
|
||||||
|
if not project_code:
|
||||||
|
return None
|
||||||
|
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")
|
||||||
|
return project.id
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_milestone(db: Session, milestone_id: int | None, milestone_code: str | None, project_id: int | None) -> Milestone | None:
|
||||||
|
if milestone_id:
|
||||||
|
query = db.query(Milestone).filter(Milestone.id == milestone_id)
|
||||||
|
if project_id:
|
||||||
|
query = query.filter(Milestone.project_id == project_id)
|
||||||
|
milestone = query.first()
|
||||||
|
elif milestone_code:
|
||||||
|
query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code)
|
||||||
|
if project_id:
|
||||||
|
query = query.filter(Milestone.project_id == project_id)
|
||||||
|
milestone = query.first()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not milestone:
|
||||||
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
return milestone
|
||||||
|
|
||||||
|
|
||||||
|
def _find_task_by_id_or_code(db: Session, identifier: str) -> Task | None:
|
||||||
|
try:
|
||||||
|
task_id = int(identifier)
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if task:
|
||||||
|
return task
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return db.query(Task).filter(Task.task_code == identifier).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_task(db: Session, task: Task) -> dict:
|
||||||
|
payload = schemas.TaskResponse.model_validate(task).model_dump(mode="json")
|
||||||
|
project = db.query(models.Project).filter(models.Project.id == task.project_id).first()
|
||||||
|
milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first()
|
||||||
|
assignee = None
|
||||||
|
if task.assignee_id:
|
||||||
|
assignee = db.query(models.User).filter(models.User.id == task.assignee_id).first()
|
||||||
|
|
||||||
|
payload.update({
|
||||||
|
"code": task.task_code,
|
||||||
|
"type": task.task_type,
|
||||||
|
"project_code": project.project_code if project else None,
|
||||||
|
"milestone_code": milestone.milestone_code if milestone else None,
|
||||||
|
"taken_by": assignee.username if assignee else None,
|
||||||
|
"due_date": None,
|
||||||
|
})
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
# ---- CRUD ----
|
# ---- CRUD ----
|
||||||
|
|
||||||
@router.post("/tasks", response_model=schemas.TaskResponse, status_code=status.HTTP_201_CREATED)
|
@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)):
|
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)
|
requested_task_type = task_in.type or task_in.task_type
|
||||||
|
_validate_task_type_subtype(requested_task_type, task_in.task_subtype)
|
||||||
|
|
||||||
data = task_in.model_dump(exclude_unset=True)
|
data = task_in.model_dump(exclude_unset=True)
|
||||||
|
if data.get("type") and not data.get("task_type"):
|
||||||
|
data["task_type"] = data.pop("type")
|
||||||
|
else:
|
||||||
|
data.pop("type", None)
|
||||||
|
|
||||||
|
data["project_id"] = _resolve_project_id(db, data.get("project_id"), data.pop("project_code", None))
|
||||||
|
milestone = _resolve_milestone(db, data.get("milestone_id"), data.pop("milestone_code", None), data.get("project_id"))
|
||||||
|
if milestone:
|
||||||
|
data["milestone_id"] = milestone.id
|
||||||
|
data["project_id"] = milestone.project_id
|
||||||
|
|
||||||
data["reporter_id"] = data.get("reporter_id") or current_user.id
|
data["reporter_id"] = data.get("reporter_id") or current_user.id
|
||||||
data["created_by_id"] = current_user.id
|
data["created_by_id"] = current_user.id
|
||||||
|
|
||||||
if not data.get("project_id"):
|
if not data.get("project_id"):
|
||||||
raise HTTPException(status_code=400, detail="project_id is required")
|
raise HTTPException(status_code=400, detail="project_id or project_code is required")
|
||||||
if not data.get("milestone_id"):
|
if not data.get("milestone_id"):
|
||||||
raise HTTPException(status_code=400, detail="milestone_id is required")
|
raise HTTPException(status_code=400, detail="milestone_id or milestone_code is required")
|
||||||
|
|
||||||
check_project_role(db, current_user.id, data["project_id"], min_role="dev")
|
check_project_role(db, current_user.id, data["project_id"], min_role="dev")
|
||||||
|
|
||||||
milestone = db.query(Milestone).filter(
|
if not milestone:
|
||||||
Milestone.id == data["milestone_id"],
|
milestone = db.query(Milestone).filter(
|
||||||
Milestone.project_id == data["project_id"],
|
Milestone.id == data["milestone_id"],
|
||||||
).first()
|
Milestone.project_id == data["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")
|
||||||
|
|
||||||
@@ -139,7 +225,7 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
|
|||||||
db,
|
db,
|
||||||
)
|
)
|
||||||
log_activity(db, "task.created", "task", db_task.id, current_user.id, {"title": db_task.title})
|
log_activity(db, "task.created", "task", db_task.id, current_user.id, {"title": db_task.title})
|
||||||
return db_task
|
return _serialize_task(db, db_task)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks")
|
@router.get("/tasks")
|
||||||
@@ -148,27 +234,51 @@ def list_tasks(
|
|||||||
assignee_id: int = None, tag: str = None,
|
assignee_id: int = None, tag: str = None,
|
||||||
sort_by: str = "created_at", sort_order: str = "desc",
|
sort_by: str = "created_at", sort_order: str = "desc",
|
||||||
page: int = 1, page_size: int = 50,
|
page: int = 1, page_size: int = 50,
|
||||||
|
project: str = None, milestone: str = None, status_value: str = Query(None, alias="status"), taken_by: str = None,
|
||||||
|
order_by: str = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
query = db.query(Task)
|
query = db.query(Task)
|
||||||
if project_id:
|
|
||||||
query = query.filter(Task.project_id == project_id)
|
resolved_project_id = _resolve_project_id(db, project_id, project)
|
||||||
if task_status:
|
if resolved_project_id:
|
||||||
query = query.filter(Task.status == task_status)
|
query = query.filter(Task.project_id == resolved_project_id)
|
||||||
|
|
||||||
|
if milestone:
|
||||||
|
milestone_obj = _resolve_milestone(db, None, milestone, resolved_project_id)
|
||||||
|
query = query.filter(Task.milestone_id == milestone_obj.id)
|
||||||
|
|
||||||
|
effective_status = status_value or task_status
|
||||||
|
if effective_status:
|
||||||
|
query = query.filter(Task.status == effective_status)
|
||||||
if task_type:
|
if task_type:
|
||||||
query = query.filter(Task.task_type == task_type)
|
query = query.filter(Task.task_type == task_type)
|
||||||
if task_subtype:
|
if task_subtype:
|
||||||
query = query.filter(Task.task_subtype == task_subtype)
|
query = query.filter(Task.task_subtype == task_subtype)
|
||||||
if assignee_id:
|
|
||||||
query = query.filter(Task.assignee_id == assignee_id)
|
effective_assignee_id = assignee_id
|
||||||
|
if taken_by == "null":
|
||||||
|
query = query.filter(Task.assignee_id.is_(None))
|
||||||
|
elif taken_by:
|
||||||
|
user = db.query(models.User).filter(models.User.username == taken_by).first()
|
||||||
|
if not user:
|
||||||
|
return {"items": [], "total": 0, "total_tasks": 0, "page": 1, "page_size": page_size, "total_pages": 1}
|
||||||
|
effective_assignee_id = user.id
|
||||||
|
if effective_assignee_id:
|
||||||
|
query = query.filter(Task.assignee_id == effective_assignee_id)
|
||||||
if tag:
|
if tag:
|
||||||
query = query.filter(Task.tags.contains(tag))
|
query = query.filter(Task.tags.contains(tag))
|
||||||
|
|
||||||
|
effective_sort_by = order_by or sort_by
|
||||||
sort_fields = {
|
sort_fields = {
|
||||||
"created_at": Task.created_at, "updated_at": Task.updated_at,
|
"created": Task.created_at,
|
||||||
"priority": Task.priority, "title": Task.title,
|
"created_at": Task.created_at,
|
||||||
|
"updated_at": Task.updated_at,
|
||||||
|
"priority": Task.priority,
|
||||||
|
"name": Task.title,
|
||||||
|
"title": Task.title,
|
||||||
}
|
}
|
||||||
sort_col = sort_fields.get(sort_by, Task.created_at)
|
sort_col = sort_fields.get(effective_sort_by, Task.created_at)
|
||||||
query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc())
|
query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc())
|
||||||
|
|
||||||
total = query.count()
|
total = query.count()
|
||||||
@@ -177,7 +287,7 @@ def list_tasks(
|
|||||||
total_pages = math.ceil(total / page_size) if total else 1
|
total_pages = math.ceil(total / page_size) if total else 1
|
||||||
items = query.offset((page - 1) * page_size).limit(page_size).all()
|
items = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||||
return {
|
return {
|
||||||
"items": [schemas.TaskResponse.model_validate(i) for i in items],
|
"items": [_serialize_task(db, i) for i in items],
|
||||||
"total": total,
|
"total": total,
|
||||||
"total_tasks": total,
|
"total_tasks": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
@@ -186,23 +296,52 @@ def list_tasks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/search", response_model=List[schemas.TaskResponse])
|
||||||
|
def search_tasks_alias(
|
||||||
|
q: str,
|
||||||
|
project: str = None,
|
||||||
|
status: str = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = db.query(Task).filter(
|
||||||
|
(Task.title.contains(q)) | (Task.description.contains(q))
|
||||||
|
)
|
||||||
|
resolved_project_id = _resolve_project_id(db, None, project)
|
||||||
|
if resolved_project_id:
|
||||||
|
query = query.filter(Task.project_id == resolved_project_id)
|
||||||
|
if status:
|
||||||
|
query = query.filter(Task.status == status)
|
||||||
|
items = query.order_by(Task.created_at.desc()).limit(100).all()
|
||||||
|
return [_serialize_task(db, i) for i in items]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}", response_model=schemas.TaskResponse)
|
@router.get("/tasks/{task_id}", response_model=schemas.TaskResponse)
|
||||||
def get_task(task_id: int, db: Session = Depends(get_db)):
|
def get_task(task_id: str, db: Session = Depends(get_db)):
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = _resolve_task(db, task_id)
|
||||||
if not task:
|
return _serialize_task(db, task)
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
|
||||||
return task
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/tasks/{task_id}", response_model=schemas.TaskResponse)
|
@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)):
|
def update_task(task_id: str, 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()
|
task = _resolve_task(db, task_id)
|
||||||
if not task:
|
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
|
||||||
|
|
||||||
# P5.7: status-based edit restrictions
|
# P5.7: status-based edit restrictions
|
||||||
current_status = task.status.value if hasattr(task.status, 'value') else task.status
|
current_status = task.status.value if hasattr(task.status, 'value') else task.status
|
||||||
update_data = task_update.model_dump(exclude_unset=True)
|
update_data = task_update.model_dump(exclude_unset=True)
|
||||||
|
if update_data.get("type") and not update_data.get("task_type"):
|
||||||
|
update_data["task_type"] = update_data.pop("type")
|
||||||
|
else:
|
||||||
|
update_data.pop("type", None)
|
||||||
|
|
||||||
|
if "taken_by" in update_data:
|
||||||
|
taken_by = update_data.pop("taken_by")
|
||||||
|
if taken_by in (None, "null", ""):
|
||||||
|
update_data["assignee_id"] = None
|
||||||
|
else:
|
||||||
|
assignee = db.query(models.User).filter(models.User.username == taken_by).first()
|
||||||
|
if not assignee:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignee user not found")
|
||||||
|
update_data["assignee_id"] = assignee.id
|
||||||
|
|
||||||
# Fields that are always allowed regardless of status (non-body edits)
|
# Fields that are always allowed regardless of status (non-body edits)
|
||||||
_always_allowed = {"status"}
|
_always_allowed = {"status"}
|
||||||
@@ -268,14 +407,12 @@ def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Dep
|
|||||||
from app.api.routers.milestone_actions import try_auto_complete_milestone
|
from app.api.routers.milestone_actions import try_auto_complete_milestone
|
||||||
try_auto_complete_milestone(db, task, user_id=current_user.id)
|
try_auto_complete_milestone(db, task, user_id=current_user.id)
|
||||||
|
|
||||||
return task
|
return _serialize_task(db, task)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@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)):
|
def delete_task(task_id: str, 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()
|
task = _resolve_task(db, task_id)
|
||||||
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")
|
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})
|
log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title})
|
||||||
db.delete(task)
|
db.delete(task)
|
||||||
@@ -286,24 +423,24 @@ def delete_task(task_id: int, db: Session = Depends(get_db), current_user: model
|
|||||||
# ---- Transition ----
|
# ---- Transition ----
|
||||||
|
|
||||||
class TransitionBody(BaseModel):
|
class TransitionBody(BaseModel):
|
||||||
|
status: Optional[str] = None
|
||||||
comment: Optional[str] = None
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse)
|
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse)
|
||||||
def transition_task(
|
def transition_task(
|
||||||
task_id: int,
|
task_id: str,
|
||||||
new_status: str,
|
|
||||||
bg: BackgroundTasks,
|
bg: BackgroundTasks,
|
||||||
|
new_status: str | None = None,
|
||||||
body: TransitionBody = None,
|
body: TransitionBody = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
):
|
):
|
||||||
|
new_status = new_status or (body.status if body else None)
|
||||||
valid_statuses = [s.value for s in TaskStatus]
|
valid_statuses = [s.value for s in TaskStatus]
|
||||||
if new_status not in valid_statuses:
|
if new_status not in valid_statuses:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {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()
|
task = _resolve_task(db, task_id)
|
||||||
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
|
old_status = task.status.value if hasattr(task.status, 'value') else task.status
|
||||||
|
|
||||||
# P5.1: enforce state-machine
|
# P5.1: enforce state-machine
|
||||||
@@ -385,16 +522,47 @@ def transition_task(
|
|||||||
bg.add_task(fire_webhooks_sync, event,
|
bg.add_task(fire_webhooks_sync, event,
|
||||||
{"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status},
|
{"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status},
|
||||||
task.project_id, db)
|
task.project_id, db)
|
||||||
return task
|
return _serialize_task(db, task)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/take", response_model=schemas.TaskResponse)
|
||||||
|
def take_task(
|
||||||
|
task_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
task = _find_task_by_id_or_code(db, task_id)
|
||||||
|
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")
|
||||||
|
|
||||||
|
if task.assignee_id and task.assignee_id != current_user.id:
|
||||||
|
assignee = db.query(models.User).filter(models.User.id == task.assignee_id).first()
|
||||||
|
assignee_name = assignee.username if assignee else str(task.assignee_id)
|
||||||
|
raise HTTPException(status_code=409, detail=f"Task is already taken by {assignee_name}")
|
||||||
|
|
||||||
|
task.assignee_id = current_user.id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
|
||||||
|
_notify_user(
|
||||||
|
db,
|
||||||
|
current_user.id,
|
||||||
|
"task.assigned",
|
||||||
|
f"Task {task.task_code or task.id} assigned to you",
|
||||||
|
f"'{task.title}' has been assigned to you.",
|
||||||
|
"task",
|
||||||
|
task.id,
|
||||||
|
)
|
||||||
|
return _serialize_task(db, task)
|
||||||
|
|
||||||
|
|
||||||
# ---- Assignment ----
|
# ---- Assignment ----
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/assign")
|
@router.post("/tasks/{task_id}/assign")
|
||||||
def assign_task(task_id: int, assignee_id: int, db: Session = Depends(get_db)):
|
def assign_task(task_id: str, assignee_id: int, db: Session = Depends(get_db)):
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = _resolve_task(db, task_id)
|
||||||
if not task:
|
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
|
||||||
user = db.query(models.User).filter(models.User.id == assignee_id).first()
|
user = db.query(models.User).filter(models.User.id == assignee_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")
|
||||||
@@ -410,10 +578,8 @@ def assign_task(task_id: int, assignee_id: int, db: Session = Depends(get_db)):
|
|||||||
# ---- Tags ----
|
# ---- Tags ----
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/tags")
|
@router.post("/tasks/{task_id}/tags")
|
||||||
def add_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
|
def add_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = _resolve_task(db, task_id)
|
||||||
if not task:
|
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
|
||||||
current = set(task.tags.split(",")) if task.tags else set()
|
current = set(task.tags.split(",")) if task.tags else set()
|
||||||
current.add(tag.strip())
|
current.add(tag.strip())
|
||||||
current.discard("")
|
current.discard("")
|
||||||
@@ -423,10 +589,8 @@ def add_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/tasks/{task_id}/tags")
|
@router.delete("/tasks/{task_id}/tags")
|
||||||
def remove_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
|
def remove_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = _resolve_task(db, task_id)
|
||||||
if not task:
|
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
|
||||||
current = set(task.tags.split(",")) if task.tags else set()
|
current = set(task.tags.split(",")) if task.tags else set()
|
||||||
current.discard(tag.strip())
|
current.discard(tag.strip())
|
||||||
current.discard("")
|
current.discard("")
|
||||||
@@ -616,7 +780,7 @@ def search_tasks(q: str, project_id: int = None, page: int = 1, page_size: int =
|
|||||||
total_pages = math.ceil(total / page_size) if total else 1
|
total_pages = math.ceil(total / page_size) if total else 1
|
||||||
items = query.offset((page - 1) * page_size).limit(page_size).all()
|
items = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||||
return {
|
return {
|
||||||
"items": [schemas.TaskResponse.model_validate(i) for i in items],
|
"items": [_serialize_task(db, i) for i in items],
|
||||||
"total": total,
|
"total": total,
|
||||||
"total_tasks": total,
|
"total_tasks": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
|
|||||||
@@ -102,31 +102,40 @@ def list_users(
|
|||||||
return db.query(models.User).order_by(models.User.created_at.desc()).offset(skip).limit(limit).all()
|
return db.query(models.User).order_by(models.User.created_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=schemas.UserResponse)
|
def _find_user_by_id_or_username(db: Session, identifier: str) -> models.User | None:
|
||||||
|
"""Resolve a user by numeric id or username string."""
|
||||||
|
try:
|
||||||
|
uid = int(identifier)
|
||||||
|
return db.query(models.User).filter(models.User.id == uid).first()
|
||||||
|
except ValueError:
|
||||||
|
return db.query(models.User).filter(models.User.username == identifier).first()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{identifier}", response_model=schemas.UserResponse)
|
||||||
def get_user(
|
def get_user(
|
||||||
user_id: int,
|
identifier: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: models.User = Depends(require_admin),
|
_: models.User = Depends(require_admin),
|
||||||
):
|
):
|
||||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
user = _find_user_by_id_or_username(db, identifier)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{user_id}", response_model=schemas.UserResponse)
|
@router.patch("/{identifier}", response_model=schemas.UserResponse)
|
||||||
def update_user(
|
def update_user(
|
||||||
user_id: int,
|
identifier: str,
|
||||||
payload: schemas.UserUpdate,
|
payload: schemas.UserUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(require_admin),
|
current_user: models.User = Depends(require_admin),
|
||||||
):
|
):
|
||||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
user = _find_user_by_id_or_username(db, identifier)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
if payload.email is not None and payload.email != user.email:
|
if payload.email is not None and payload.email != user.email:
|
||||||
existing = db.query(models.User).filter(models.User.email == payload.email, models.User.id != user_id).first()
|
existing = db.query(models.User).filter(models.User.email == payload.email, models.User.id != user.id).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Email already exists")
|
raise HTTPException(status_code=400, detail="Email already exists")
|
||||||
user.email = payload.email
|
user.email = payload.email
|
||||||
@@ -153,17 +162,22 @@ def update_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_user(
|
def delete_user(
|
||||||
user_id: int,
|
identifier: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(require_admin),
|
current_user: models.User = Depends(require_admin),
|
||||||
):
|
):
|
||||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
user = _find_user_by_id_or_username(db, identifier)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
if current_user.id == user.id:
|
if current_user.id == user.id:
|
||||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||||
|
# Protect built-in accounts from deletion
|
||||||
|
if user.is_admin:
|
||||||
|
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
|
||||||
|
if user.username == "acc-mgr":
|
||||||
|
raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
|
||||||
try:
|
try:
|
||||||
db.delete(user)
|
db.delete(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -173,6 +187,65 @@ def delete_user(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{identifier}/reset-apikey")
|
||||||
|
def reset_user_apikey(
|
||||||
|
identifier: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Reset (regenerate) a user's API key.
|
||||||
|
|
||||||
|
Permission rules:
|
||||||
|
- user.reset-apikey: can reset any user's API key
|
||||||
|
- user.reset-self-apikey: can reset only own API key
|
||||||
|
- admin: can reset any user's API key
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from app.models.apikey import APIKey
|
||||||
|
|
||||||
|
target_user = _find_user_by_id_or_username(db, identifier)
|
||||||
|
if not target_user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
is_self = current_user.id == target_user.id
|
||||||
|
can_reset_any = _has_global_permission(db, current_user, "user.reset-apikey")
|
||||||
|
can_reset_self = _has_global_permission(db, current_user, "user.reset-self-apikey")
|
||||||
|
|
||||||
|
if not (can_reset_any or (is_self and can_reset_self)):
|
||||||
|
raise HTTPException(status_code=403, detail="API key reset permission required")
|
||||||
|
|
||||||
|
# Find existing active API key for target user, or create one
|
||||||
|
existing_key = db.query(APIKey).filter(
|
||||||
|
APIKey.user_id == target_user.id,
|
||||||
|
APIKey.is_active == True,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
new_key_value = secrets.token_hex(32)
|
||||||
|
|
||||||
|
if existing_key:
|
||||||
|
# Deactivate old key
|
||||||
|
existing_key.is_active = False
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Create new key
|
||||||
|
new_key = APIKey(
|
||||||
|
key=new_key_value,
|
||||||
|
name=f"{target_user.username}-key",
|
||||||
|
user_id=target_user.id,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(new_key)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": target_user.id,
|
||||||
|
"username": target_user.username,
|
||||||
|
"api_key": new_key_value,
|
||||||
|
"message": "API key has been reset. Please save this key — it will not be shown again.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WorkLogResponse(BaseModel):
|
class WorkLogResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
task_id: int
|
task_id: int
|
||||||
@@ -186,13 +259,16 @@ class WorkLogResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}/worklogs", response_model=List[WorkLogResponse])
|
@router.get("/{identifier}/worklogs", response_model=List[WorkLogResponse])
|
||||||
def list_user_worklogs(
|
def list_user_worklogs(
|
||||||
user_id: int,
|
identifier: str,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
if current_user.id != user_id and not current_user.is_admin:
|
user = _find_user_by_id_or_username(db, identifier)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if current_user.id != user.id and not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
return db.query(WorkLog).filter(WorkLog.user_id == user_id).order_by(WorkLog.logged_date.desc()).limit(limit).all()
|
return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all()
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ DEFAULT_PERMISSIONS = [
|
|||||||
("account.create", "Create HarborForge accounts", "account"),
|
("account.create", "Create HarborForge accounts", "account"),
|
||||||
# User management
|
# User management
|
||||||
("user.manage", "Manage users", "admin"),
|
("user.manage", "Manage users", "admin"),
|
||||||
|
# API key management
|
||||||
|
("user.reset-self-apikey", "Reset own API key", "user"),
|
||||||
|
("user.reset-apikey", "Reset any user's API key", "admin"),
|
||||||
# Monitor
|
# Monitor
|
||||||
("monitor.read", "View monitor", "monitor"),
|
("monitor.read", "View monitor", "monitor"),
|
||||||
("monitor.manage", "Manage monitor", "monitor"),
|
("monitor.manage", "Manage monitor", "monitor"),
|
||||||
@@ -165,6 +168,7 @@ _MGR_PERMISSIONS = {
|
|||||||
"task.close", "task.reopen_closed", "task.reopen_completed",
|
"task.close", "task.reopen_closed", "task.reopen_completed",
|
||||||
"propose.accept", "propose.reject", "propose.reopen",
|
"propose.accept", "propose.reject", "propose.reopen",
|
||||||
"monitor.read",
|
"monitor.read",
|
||||||
|
"user.reset-self-apikey",
|
||||||
}
|
}
|
||||||
|
|
||||||
# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject propose
|
# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject propose
|
||||||
@@ -174,6 +178,7 @@ _DEV_PERMISSIONS = {
|
|||||||
"milestone.read",
|
"milestone.read",
|
||||||
"task.close", "task.reopen_closed", "task.reopen_completed",
|
"task.close", "task.reopen_closed", "task.reopen_completed",
|
||||||
"monitor.read",
|
"monitor.read",
|
||||||
|
"user.reset-self-apikey",
|
||||||
}
|
}
|
||||||
|
|
||||||
_ACCOUNT_MANAGER_PERMISSIONS = {
|
_ACCOUNT_MANAGER_PERMISSIONS = {
|
||||||
@@ -246,6 +251,43 @@ def init_admin_role(db: Session, admin_user: models.User) -> None:
|
|||||||
logger.info("Default roles setup complete (admin, mgr, dev, guest)")
|
logger.info("Default roles setup complete (admin, mgr, dev, guest)")
|
||||||
|
|
||||||
|
|
||||||
|
def init_acc_mgr_user(db: Session) -> models.User | None:
|
||||||
|
"""Create the built-in acc-mgr user if not exists.
|
||||||
|
|
||||||
|
This user:
|
||||||
|
- Has role 'account-manager' (can only create accounts)
|
||||||
|
- Cannot log in (no password, hashed_password=None)
|
||||||
|
- Cannot be deleted (enforced in delete endpoint)
|
||||||
|
- Is created automatically after wizard initialization
|
||||||
|
"""
|
||||||
|
username = "acc-mgr"
|
||||||
|
existing = db.query(models.User).filter(models.User.username == username).first()
|
||||||
|
if existing:
|
||||||
|
logger.info("acc-mgr user already exists (id=%d), skipping", existing.id)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Find account-manager role
|
||||||
|
acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first()
|
||||||
|
if not acc_mgr_role:
|
||||||
|
logger.warning("account-manager role not found, skipping acc-mgr user creation")
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = models.User(
|
||||||
|
username=username,
|
||||||
|
email="acc-mgr@harborforge.internal",
|
||||||
|
full_name="Account Manager",
|
||||||
|
hashed_password=None, # Cannot log in — no password
|
||||||
|
is_admin=False,
|
||||||
|
is_active=True,
|
||||||
|
role_id=acc_mgr_role.id,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
logger.info("Created acc-mgr user (id=%d) with account-manager role", user.id)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def run_init(db: Session) -> None:
|
def run_init(db: Session) -> None:
|
||||||
"""Main initialization entry point. Reads config from shared volume."""
|
"""Main initialization entry point. Reads config from shared volume."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -267,6 +309,9 @@ def run_init(db: Session) -> None:
|
|||||||
if admin_user:
|
if admin_user:
|
||||||
init_admin_role(db, admin_user)
|
init_admin_role(db, admin_user)
|
||||||
|
|
||||||
|
# Built-in acc-mgr user (after roles are created)
|
||||||
|
init_acc_mgr_user(db)
|
||||||
|
|
||||||
# Default project
|
# Default project
|
||||||
project_cfg = config.get("default_project")
|
project_cfg = config.get("default_project")
|
||||||
if project_cfg and admin_user:
|
if project_cfg and admin_user:
|
||||||
|
|||||||
22
app/main.py
22
app/main.py
@@ -26,6 +26,26 @@ def health_check():
|
|||||||
def version():
|
def version():
|
||||||
return {"name": "HarborForge", "version": "0.3.0", "description": "Agent/人类协同任务管理平台"}
|
return {"name": "HarborForge", "version": "0.3.0", "description": "Agent/人类协同任务管理平台"}
|
||||||
|
|
||||||
|
@app.get("/config/status", tags=["System"])
|
||||||
|
def config_status():
|
||||||
|
"""Check if HarborForge has been initialized (reads from config volume).
|
||||||
|
Frontend uses this instead of contacting the wizard directly."""
|
||||||
|
import os, json
|
||||||
|
config_dir = os.getenv("CONFIG_DIR", "/config")
|
||||||
|
config_file = os.getenv("CONFIG_FILE", "harborforge.json")
|
||||||
|
config_path = os.path.join(config_dir, config_file)
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
return {"initialized": False}
|
||||||
|
try:
|
||||||
|
with open(config_path, "r") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
return {
|
||||||
|
"initialized": cfg.get("initialized", False),
|
||||||
|
"backend_url": cfg.get("backend_url"),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {"initialized": False}
|
||||||
|
|
||||||
# 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.tasks import router as tasks_router
|
from app.api.routers.tasks import router as tasks_router
|
||||||
@@ -39,6 +59,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
|
||||||
from app.api.routers.proposes import router as proposes_router
|
from app.api.routers.proposes import router as proposes_router
|
||||||
from app.api.routers.milestone_actions import router as milestone_actions_router
|
from app.api.routers.milestone_actions import router as milestone_actions_router
|
||||||
|
from app.api.routers.meetings import router as meetings_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(tasks_router)
|
app.include_router(tasks_router)
|
||||||
@@ -52,6 +73,7 @@ app.include_router(milestones_router)
|
|||||||
app.include_router(roles_router)
|
app.include_router(roles_router)
|
||||||
app.include_router(proposes_router)
|
app.include_router(proposes_router)
|
||||||
app.include_router(milestone_actions_router)
|
app.include_router(milestone_actions_router)
|
||||||
|
app.include_router(meetings_router)
|
||||||
|
|
||||||
|
|
||||||
# Auto schema migration for lightweight deployments
|
# Auto schema migration for lightweight deployments
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, UniqueConstraint
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.core.config import Base
|
from app.core.config import Base
|
||||||
@@ -35,3 +35,19 @@ class Meeting(Base):
|
|||||||
|
|
||||||
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())
|
||||||
|
|
||||||
|
participants = relationship("MeetingParticipant", back_populates="meeting", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class MeetingParticipant(Base):
|
||||||
|
__tablename__ = "meeting_participants"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("meeting_id", "user_id", name="uq_meeting_participant"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
meeting_id = Column(Integer, ForeignKey("meetings.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
joined_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
meeting = relationship("Meeting", back_populates="participants")
|
||||||
|
|||||||
@@ -44,9 +44,12 @@ class TaskBase(BaseModel):
|
|||||||
|
|
||||||
class TaskCreate(TaskBase):
|
class TaskCreate(TaskBase):
|
||||||
project_id: Optional[int] = None
|
project_id: Optional[int] = None
|
||||||
|
project_code: Optional[str] = None
|
||||||
milestone_id: Optional[int] = None
|
milestone_id: Optional[int] = None
|
||||||
|
milestone_code: Optional[str] = None
|
||||||
reporter_id: Optional[int] = None
|
reporter_id: Optional[int] = None
|
||||||
assignee_id: Optional[int] = None
|
assignee_id: Optional[int] = None
|
||||||
|
type: Optional[TaskTypeEnum] = None
|
||||||
# Resolution specific
|
# Resolution specific
|
||||||
resolution_summary: Optional[str] = None
|
resolution_summary: Optional[str] = None
|
||||||
positions: Optional[str] = None
|
positions: Optional[str] = None
|
||||||
@@ -57,10 +60,12 @@ class TaskUpdate(BaseModel):
|
|||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
task_type: Optional[TaskTypeEnum] = None
|
task_type: Optional[TaskTypeEnum] = None
|
||||||
|
type: Optional[TaskTypeEnum] = None
|
||||||
task_subtype: Optional[str] = None
|
task_subtype: Optional[str] = None
|
||||||
status: Optional[TaskStatusEnum] = None
|
status: Optional[TaskStatusEnum] = None
|
||||||
priority: Optional[TaskPriorityEnum] = None
|
priority: Optional[TaskPriorityEnum] = None
|
||||||
assignee_id: Optional[int] = None
|
assignee_id: Optional[int] = None
|
||||||
|
taken_by: Optional[str] = None
|
||||||
tags: Optional[str] = None
|
tags: Optional[str] = None
|
||||||
estimated_effort: Optional[int] = None
|
estimated_effort: Optional[int] = None
|
||||||
# Resolution specific
|
# Resolution specific
|
||||||
@@ -73,10 +78,16 @@ class TaskResponse(TaskBase):
|
|||||||
id: int
|
id: int
|
||||||
status: TaskStatusEnum
|
status: TaskStatusEnum
|
||||||
task_code: Optional[str] = None
|
task_code: Optional[str] = None
|
||||||
|
code: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
due_date: Optional[datetime] = None
|
||||||
project_id: int
|
project_id: int
|
||||||
|
project_code: Optional[str] = None
|
||||||
milestone_id: int
|
milestone_id: int
|
||||||
|
milestone_code: Optional[str] = None
|
||||||
reporter_id: int
|
reporter_id: int
|
||||||
assignee_id: Optional[int] = None
|
assignee_id: Optional[int] = None
|
||||||
|
taken_by: Optional[str] = None
|
||||||
created_by_id: Optional[int] = None
|
created_by_id: Optional[int] = None
|
||||||
estimated_working_time: Optional[time] = None
|
estimated_working_time: Optional[time] = None
|
||||||
resolution_summary: Optional[str] = None
|
resolution_summary: Optional[str] = None
|
||||||
@@ -197,6 +208,8 @@ class ProjectMemberCreate(ProjectMemberBase):
|
|||||||
class ProjectMemberResponse(BaseModel):
|
class ProjectMemberResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
|
username: Optional[str] = None
|
||||||
|
full_name: Optional[str] = None
|
||||||
project_id: int
|
project_id: int
|
||||||
role: str = "dev"
|
role: str = "dev"
|
||||||
|
|
||||||
@@ -240,6 +253,7 @@ class MilestoneUpdate(BaseModel):
|
|||||||
|
|
||||||
class MilestoneResponse(MilestoneBase):
|
class MilestoneResponse(MilestoneBase):
|
||||||
id: int
|
id: int
|
||||||
|
milestone_code: Optional[str] = None
|
||||||
project_id: int
|
project_id: int
|
||||||
created_by_id: Optional[int] = None
|
created_by_id: Optional[int] = None
|
||||||
started_at: Optional[datetime] = None
|
started_at: Optional[datetime] = None
|
||||||
@@ -278,6 +292,7 @@ class ProposeResponse(ProposeBase):
|
|||||||
status: ProposeStatusEnum
|
status: ProposeStatusEnum
|
||||||
project_id: int
|
project_id: int
|
||||||
created_by_id: Optional[int] = None
|
created_by_id: Optional[int] = None
|
||||||
|
created_by_username: Optional[str] = None
|
||||||
feat_task_id: Optional[str] = None
|
feat_task_id: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|||||||
408
cli.py
408
cli.py
@@ -1,408 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""HarborForge CLI - 简易命令行工具"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
BASE_URL = os.environ.get("HARBORFORGE_URL", "http://localhost:8000")
|
|
||||||
TOKEN = os.environ.get("HARBORFORGE_TOKEN", "")
|
|
||||||
|
|
||||||
|
|
||||||
STATUS_ICON = {
|
|
||||||
"open": "🟢",
|
|
||||||
"pending": "🟡",
|
|
||||||
"freeze": "🧊",
|
|
||||||
"undergoing": "🔵",
|
|
||||||
"completed": "✅",
|
|
||||||
"closed": "⚫",
|
|
||||||
}
|
|
||||||
TYPE_ICON = {
|
|
||||||
"resolution": "⚖️",
|
|
||||||
"story": "📖",
|
|
||||||
"test": "🧪",
|
|
||||||
"issue": "📌",
|
|
||||||
"maintenance": "🛠️",
|
|
||||||
"research": "🔬",
|
|
||||||
"review": "🧐",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _request(method, path, data=None):
|
|
||||||
url = f"{BASE_URL}{path}"
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
if TOKEN:
|
|
||||||
headers["Authorization"] = f"Bearer {TOKEN}"
|
|
||||||
|
|
||||||
body = json.dumps(data).encode() if data is not None else None
|
|
||||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as resp:
|
|
||||||
if resp.status == 204:
|
|
||||||
return None
|
|
||||||
raw = resp.read()
|
|
||||||
return json.loads(raw) if raw else None
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_login(args):
|
|
||||||
data = urllib.parse.urlencode({"username": args.username, "password": args.password}).encode()
|
|
||||||
req = urllib.request.Request(f"{BASE_URL}/auth/token", data=data, method="POST")
|
|
||||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as resp:
|
|
||||||
result = json.loads(resp.read())
|
|
||||||
print(f"Token: {result['access_token']}")
|
|
||||||
print(f"\nExport it:\nexport HARBORFORGE_TOKEN={result['access_token']}")
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
print(f"Login failed: {e.read().decode()}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_tasks(args):
|
|
||||||
params = []
|
|
||||||
if args.project:
|
|
||||||
params.append(f"project_id={args.project}")
|
|
||||||
if args.type:
|
|
||||||
params.append(f"task_type={args.type}")
|
|
||||||
if args.status:
|
|
||||||
params.append(f"task_status={args.status}")
|
|
||||||
qs = f"?{'&'.join(params)}" if params else ""
|
|
||||||
result = _request("GET", f"/tasks{qs}")
|
|
||||||
items = result.get("items", result if isinstance(result, list) else [])
|
|
||||||
for task in items:
|
|
||||||
status_icon = STATUS_ICON.get(task["status"], "❓")
|
|
||||||
type_icon = TYPE_ICON.get(task.get("task_type"), "📌")
|
|
||||||
print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_task_create(args):
|
|
||||||
data = {
|
|
||||||
"title": args.title,
|
|
||||||
"project_id": args.project,
|
|
||||||
"milestone_id": args.milestone,
|
|
||||||
"reporter_id": args.reporter,
|
|
||||||
"task_type": args.type,
|
|
||||||
"priority": args.priority or "medium",
|
|
||||||
}
|
|
||||||
if args.description:
|
|
||||||
data["description"] = args.description
|
|
||||||
if args.assignee:
|
|
||||||
data["assignee_id"] = args.assignee
|
|
||||||
if args.subtype:
|
|
||||||
data["task_subtype"] = args.subtype
|
|
||||||
|
|
||||||
if args.type == "resolution":
|
|
||||||
if args.summary:
|
|
||||||
data["resolution_summary"] = args.summary
|
|
||||||
if args.positions:
|
|
||||||
data["positions"] = args.positions
|
|
||||||
if args.pending:
|
|
||||||
data["pending_matters"] = args.pending
|
|
||||||
|
|
||||||
result = _request("POST", "/tasks", data)
|
|
||||||
print(f"Created task #{result['id']}: {result['title']}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_projects(args):
|
|
||||||
projects = _request("GET", "/projects")
|
|
||||||
for project in projects:
|
|
||||||
print(f" #{project['id']} {project['name']} - {project.get('description', '')}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_users(args):
|
|
||||||
users = _request("GET", "/users")
|
|
||||||
for user in users:
|
|
||||||
role = "👑" if user["is_admin"] else "👤"
|
|
||||||
print(f" {role} #{user['id']} {user['username']} ({user.get('full_name', '')})")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_version(args):
|
|
||||||
result = _request("GET", "/version")
|
|
||||||
print(f"{result['name']} v{result['version']}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_health(args):
|
|
||||||
result = _request("GET", "/health")
|
|
||||||
print(f"Status: {result['status']}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_search(args):
|
|
||||||
params = [f"q={urllib.parse.quote(args.query)}"]
|
|
||||||
if args.project:
|
|
||||||
params.append(f"project_id={args.project}")
|
|
||||||
result = _request("GET", f"/search/tasks?{'&'.join(params)}")
|
|
||||||
items = result.get("items", result if isinstance(result, list) else [])
|
|
||||||
if not items:
|
|
||||||
print(" No results found.")
|
|
||||||
return
|
|
||||||
for task in items:
|
|
||||||
status_icon = STATUS_ICON.get(task["status"], "❓")
|
|
||||||
type_icon = TYPE_ICON.get(task.get("task_type"), "📌")
|
|
||||||
print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_transition(args):
|
|
||||||
body = {}
|
|
||||||
if args.comment:
|
|
||||||
body["comment"] = args.comment
|
|
||||||
result = _request("POST", f"/tasks/{args.task_id}/transition?new_status={args.status}", body or None)
|
|
||||||
print(f"Task #{result['id']} transitioned to: {result['status']}")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Propose commands ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def cmd_proposes(args):
|
|
||||||
if not args.project:
|
|
||||||
print("Error: --project is required for proposes", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
result = _request("GET", f"/projects/{args.project}/proposes")
|
|
||||||
items = result if isinstance(result, list) else result.get("items", [])
|
|
||||||
if not items:
|
|
||||||
print(" No proposes found.")
|
|
||||||
return
|
|
||||||
for p in items:
|
|
||||||
status_icon = STATUS_ICON.get(p["status"], "❓")
|
|
||||||
feat = f" → task {p['feat_task_id']}" if p.get("feat_task_id") else ""
|
|
||||||
print(f" {status_icon} 💡 {p['propose_code']} {p['title']}{feat}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_propose_create(args):
|
|
||||||
data = {"title": args.title}
|
|
||||||
if args.description:
|
|
||||||
data["description"] = args.description
|
|
||||||
result = _request("POST", f"/projects/{args.project}/proposes", data)
|
|
||||||
print(f"Created propose {result['propose_code']}: {result['title']}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_propose_accept(args):
|
|
||||||
result = _request("POST", f"/projects/{args.project}/proposes/{args.propose_id}/accept?milestone_id={args.milestone}")
|
|
||||||
print(f"Propose #{args.propose_id} accepted → task {result.get('feat_task_id', '?')}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_propose_reject(args):
|
|
||||||
data = {}
|
|
||||||
if args.reason:
|
|
||||||
data["reason"] = args.reason
|
|
||||||
result = _request("POST", f"/projects/{args.project}/proposes/{args.propose_id}/reject", data or None)
|
|
||||||
print(f"Propose #{args.propose_id} rejected")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_propose_reopen(args):
|
|
||||||
result = _request("POST", f"/projects/{args.project}/proposes/{args.propose_id}/reopen")
|
|
||||||
print(f"Propose #{args.propose_id} reopened")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_stats(args):
|
|
||||||
params = f"?project_id={args.project}" if args.project else ""
|
|
||||||
stats = _request("GET", f"/dashboard/stats{params}")
|
|
||||||
print(f"Total: {stats['total_tasks']}")
|
|
||||||
print("By status:")
|
|
||||||
for status_name, count in stats["by_status"].items():
|
|
||||||
if count > 0:
|
|
||||||
print(f" {status_name}: {count}")
|
|
||||||
print("By type:")
|
|
||||||
for task_type, count in stats["by_type"].items():
|
|
||||||
if count > 0:
|
|
||||||
print(f" {task_type}: {count}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_milestones(args):
|
|
||||||
params = []
|
|
||||||
if args.project:
|
|
||||||
params.append(f"project_id={args.project}")
|
|
||||||
if args.status:
|
|
||||||
params.append(f"status={args.status}")
|
|
||||||
qs = f"?{'&'.join(params)}" if params else ""
|
|
||||||
milestones = _request("GET", f"/milestones{qs}")
|
|
||||||
if not milestones:
|
|
||||||
print(" No milestones found.")
|
|
||||||
return
|
|
||||||
for milestone in milestones:
|
|
||||||
status_icon = STATUS_ICON.get(milestone["status"], "⚪")
|
|
||||||
due = f" (due: {milestone['due_date'][:10]})" if milestone.get("due_date") else ""
|
|
||||||
print(f" {status_icon} #{milestone['id']} {milestone['title']}{due}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_milestone_progress(args):
|
|
||||||
result = _request("GET", f"/milestones/{args.milestone_id}/progress")
|
|
||||||
bar_len = 20
|
|
||||||
filled = int(bar_len * result["progress_pct"] / 100)
|
|
||||||
bar = "█" * filled + "░" * (bar_len - filled)
|
|
||||||
print(f" {result['title']}")
|
|
||||||
print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_tasks']})")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_notifications(args):
|
|
||||||
params = []
|
|
||||||
if args.unread:
|
|
||||||
params.append("unread_only=true")
|
|
||||||
qs = f"?{'&'.join(params)}" if params else ""
|
|
||||||
notifications = _request("GET", f"/notifications{qs}")
|
|
||||||
if not notifications:
|
|
||||||
print(" No notifications.")
|
|
||||||
return
|
|
||||||
for notification in notifications:
|
|
||||||
icon = "🔴" if not notification["is_read"] else "⚪"
|
|
||||||
print(f" {icon} [{notification['type']}] {notification.get('message') or notification['title']}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_overdue(args):
|
|
||||||
print("Overdue tasks are not supported by the current milestone-based task schema.")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_log_time(args):
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"task_id": args.task_id,
|
|
||||||
"user_id": args.user_id,
|
|
||||||
"hours": args.hours,
|
|
||||||
"logged_date": datetime.utcnow().isoformat(),
|
|
||||||
}
|
|
||||||
if args.desc:
|
|
||||||
data["description"] = args.desc
|
|
||||||
result = _request("POST", "/worklogs", data)
|
|
||||||
print(f"Logged {result['hours']}h on task #{result['task_id']} (log #{result['id']})")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_worklogs(args):
|
|
||||||
logs = _request("GET", f"/tasks/{args.task_id}/worklogs")
|
|
||||||
for log in logs:
|
|
||||||
desc = f" - {log['description']}" if log.get("description") else ""
|
|
||||||
print(f" [{log['id']}] {log['hours']}h by user#{log['user_id']} on {log['logged_date']}{desc}")
|
|
||||||
summary = _request("GET", f"/tasks/{args.task_id}/worklogs/summary")
|
|
||||||
print(f" Total: {summary['total_hours']}h ({summary['log_count']} logs)")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="HarborForge CLI")
|
|
||||||
sub = parser.add_subparsers(dest="command")
|
|
||||||
|
|
||||||
p_login = sub.add_parser("login", help="Login and get token")
|
|
||||||
p_login.add_argument("username")
|
|
||||||
p_login.add_argument("password")
|
|
||||||
|
|
||||||
p_tasks = sub.add_parser("tasks", aliases=["issues"], help="List tasks")
|
|
||||||
p_tasks.add_argument("--project", "-p", type=int)
|
|
||||||
p_tasks.add_argument("--type", "-t", choices=["story", "test", "resolution", "issue", "maintenance", "research", "review"])
|
|
||||||
p_tasks.add_argument("--status", "-s", choices=["open", "pending", "undergoing", "completed", "closed"])
|
|
||||||
|
|
||||||
p_create = sub.add_parser("create-task", aliases=["create-issue"], help="Create a task")
|
|
||||||
p_create.add_argument("title")
|
|
||||||
p_create.add_argument("--project", "-p", type=int, required=True)
|
|
||||||
p_create.add_argument("--milestone", "-m", type=int, required=True)
|
|
||||||
p_create.add_argument("--reporter", "-r", type=int, required=True)
|
|
||||||
p_create.add_argument("--type", "-t", default="issue", choices=["story", "test", "resolution", "issue", "maintenance", "research", "review"])
|
|
||||||
p_create.add_argument("--subtype")
|
|
||||||
p_create.add_argument("--priority", choices=["low", "medium", "high", "critical"])
|
|
||||||
p_create.add_argument("--description", "-d")
|
|
||||||
p_create.add_argument("--assignee", "-a", type=int)
|
|
||||||
p_create.add_argument("--summary")
|
|
||||||
p_create.add_argument("--positions")
|
|
||||||
p_create.add_argument("--pending")
|
|
||||||
|
|
||||||
sub.add_parser("projects", help="List projects")
|
|
||||||
sub.add_parser("users", help="List users")
|
|
||||||
sub.add_parser("version", help="Show version")
|
|
||||||
sub.add_parser("health", help="Health check")
|
|
||||||
|
|
||||||
p_search = sub.add_parser("search", help="Search tasks")
|
|
||||||
p_search.add_argument("query")
|
|
||||||
p_search.add_argument("--project", "-p", type=int)
|
|
||||||
|
|
||||||
p_trans = sub.add_parser("transition", help="Transition task status")
|
|
||||||
p_trans.add_argument("task_id", type=int)
|
|
||||||
p_trans.add_argument("status", choices=["open", "pending", "undergoing", "completed", "closed"])
|
|
||||||
p_trans.add_argument("--comment", "-c", help="Comment (required for undergoing→completed)")
|
|
||||||
|
|
||||||
p_stats = sub.add_parser("stats", help="Dashboard stats")
|
|
||||||
p_stats.add_argument("--project", "-p", type=int)
|
|
||||||
|
|
||||||
p_ms = sub.add_parser("milestones", help="List milestones")
|
|
||||||
p_ms.add_argument("--project", "-p", type=int)
|
|
||||||
p_ms.add_argument("--status", "-s", choices=["open", "freeze", "undergoing", "completed", "closed"])
|
|
||||||
|
|
||||||
p_msp = sub.add_parser("milestone-progress", help="Show milestone progress")
|
|
||||||
p_msp.add_argument("milestone_id", type=int)
|
|
||||||
|
|
||||||
p_notif = sub.add_parser("notifications", help="List notifications for current token user")
|
|
||||||
p_notif.add_argument("--unread", action="store_true")
|
|
||||||
|
|
||||||
p_overdue = sub.add_parser("overdue", help="Explain overdue-task support status")
|
|
||||||
p_overdue.add_argument("--project", "-p", type=int)
|
|
||||||
|
|
||||||
p_logtime = sub.add_parser("log-time", help="Log time on a task")
|
|
||||||
p_logtime.add_argument("task_id", type=int)
|
|
||||||
p_logtime.add_argument("user_id", type=int)
|
|
||||||
p_logtime.add_argument("hours", type=float)
|
|
||||||
p_logtime.add_argument("--desc", "-d", type=str)
|
|
||||||
|
|
||||||
p_worklogs = sub.add_parser("worklogs", help="List work logs for a task")
|
|
||||||
p_worklogs.add_argument("task_id", type=int)
|
|
||||||
|
|
||||||
# ── Propose subcommands ──
|
|
||||||
p_proposes = sub.add_parser("proposes", help="List proposes for a project")
|
|
||||||
p_proposes.add_argument("--project", "-p", type=int, required=True)
|
|
||||||
|
|
||||||
p_pc = sub.add_parser("propose-create", help="Create a propose")
|
|
||||||
p_pc.add_argument("title")
|
|
||||||
p_pc.add_argument("--project", "-p", type=int, required=True)
|
|
||||||
p_pc.add_argument("--description", "-d")
|
|
||||||
|
|
||||||
p_pa = sub.add_parser("propose-accept", help="Accept a propose into a milestone")
|
|
||||||
p_pa.add_argument("propose_id", type=int)
|
|
||||||
p_pa.add_argument("--project", "-p", type=int, required=True)
|
|
||||||
p_pa.add_argument("--milestone", "-m", type=int, required=True)
|
|
||||||
|
|
||||||
p_pr = sub.add_parser("propose-reject", help="Reject a propose")
|
|
||||||
p_pr.add_argument("propose_id", type=int)
|
|
||||||
p_pr.add_argument("--project", "-p", type=int, required=True)
|
|
||||||
p_pr.add_argument("--reason", "-r")
|
|
||||||
|
|
||||||
p_pro = sub.add_parser("propose-reopen", help="Reopen a rejected propose")
|
|
||||||
p_pro.add_argument("propose_id", type=int)
|
|
||||||
p_pro.add_argument("--project", "-p", type=int, required=True)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
if not args.command:
|
|
||||||
parser.print_help()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
cmds = {
|
|
||||||
"login": cmd_login,
|
|
||||||
"tasks": cmd_tasks,
|
|
||||||
"issues": cmd_tasks,
|
|
||||||
"create-task": cmd_task_create,
|
|
||||||
"create-issue": cmd_task_create,
|
|
||||||
"projects": cmd_projects,
|
|
||||||
"users": cmd_users,
|
|
||||||
"version": cmd_version,
|
|
||||||
"health": cmd_health,
|
|
||||||
"search": cmd_search,
|
|
||||||
"transition": cmd_transition,
|
|
||||||
"stats": cmd_stats,
|
|
||||||
"milestones": cmd_milestones,
|
|
||||||
"milestone-progress": cmd_milestone_progress,
|
|
||||||
"notifications": cmd_notifications,
|
|
||||||
"overdue": cmd_overdue,
|
|
||||||
"log-time": cmd_log_time,
|
|
||||||
"worklogs": cmd_worklogs,
|
|
||||||
"proposes": cmd_proposes,
|
|
||||||
"propose-create": cmd_propose_create,
|
|
||||||
"propose-accept": cmd_propose_accept,
|
|
||||||
"propose-reject": cmd_propose_reject,
|
|
||||||
"propose-reopen": cmd_propose_reopen,
|
|
||||||
}
|
|
||||||
cmds[args.command](args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -5,9 +5,10 @@
|
|||||||
## 当前架构
|
## 当前架构
|
||||||
|
|
||||||
- HarborForge Monitor Backend 提供服务器注册与遥测接收接口
|
- HarborForge Monitor Backend 提供服务器注册与遥测接收接口
|
||||||
- OpenClaw Gateway 加载 `harborforge-monitor` 插件
|
- OpenClaw Gateway 加载 `harbor-forge` 插件
|
||||||
- 插件在 `gateway_start` 时启动 sidecar (`server/telemetry.mjs`)
|
- 旧 sidecar (`server/telemetry.mjs`) 已移除
|
||||||
- sidecar 通过 **HTTP + X-API-Key** 向 Backend 上报遥测
|
- 插件通过 Gateway/runtime 路径直接提供 OpenClaw 元数据
|
||||||
|
- Monitor 可选通过本地 `monitor_port` 桥接读取补充信息
|
||||||
|
|
||||||
## 当前后端接口
|
## 当前后端接口
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
## 数据语义
|
## 数据语义
|
||||||
|
|
||||||
- `openclaw_version`: 远程服务器上的 OpenClaw 版本
|
- `openclaw_version`: 远程服务器上的 OpenClaw 版本
|
||||||
- `plugin_version`: 远程服务器上的 harborforge-monitor 插件版本
|
- `plugin_version`: 远程服务器上的 `harbor-forge` 插件版本
|
||||||
|
|
||||||
## 已废弃内容
|
## 已废弃内容
|
||||||
|
|
||||||
@@ -67,10 +68,10 @@ Monitor 管理页应提供:
|
|||||||
1. 管理员在 Monitor 中注册服务器
|
1. 管理员在 Monitor 中注册服务器
|
||||||
2. 管理员为服务器生成 API Key
|
2. 管理员为服务器生成 API Key
|
||||||
3. 将 API Key 写入 `~/.openclaw/openclaw.json`
|
3. 将 API Key 写入 `~/.openclaw/openclaw.json`
|
||||||
4. 重启 OpenClaw Gateway
|
4. 如需本地桥接补充信息,配置 `monitor_port`
|
||||||
5. 插件启动 sidecar
|
5. 重启 OpenClaw Gateway
|
||||||
6. sidecar 定时向 `/monitor/server/heartbeat-v2` 上报
|
6. 插件直接参与遥测链路;若本地桥接可达,则额外提供 OpenClaw 补充元数据
|
||||||
|
|
||||||
## 备注
|
## 备注
|
||||||
|
|
||||||
当前保留了对旧 challenge 数据表的**删除兼容清理**(仅为兼容老数据库中的遗留数据),但不再保留 challenge 功能入口与运行时逻辑。
|
当前保留了对旧 challenge 数据表的**删除兼容清理**(仅为兼容老数据库中的遗留数据),但不再保留 challenge 功能入口、WebSocket 方案或 sidecar 运行时逻辑。
|
||||||
|
|||||||
@@ -38,13 +38,14 @@
|
|||||||
## 语义
|
## 语义
|
||||||
|
|
||||||
- `openclaw_version`: 远程主机上的 OpenClaw 版本
|
- `openclaw_version`: 远程主机上的 OpenClaw 版本
|
||||||
- `plugin_version`: harborforge-monitor 插件版本
|
- `plugin_version`: `harbor-forge` 插件版本
|
||||||
|
|
||||||
## 插件生命周期
|
## 插件生命周期
|
||||||
|
|
||||||
- 插件注册到 Gateway
|
- 插件注册名为 `harbor-forge`
|
||||||
- 在 `gateway_start` 启动 `server/telemetry.mjs`
|
- 不再启动独立 `server/telemetry.mjs` sidecar
|
||||||
- 在 `gateway_stop` 停止 sidecar
|
- 插件直接通过 Gateway/runtime 路径暴露 OpenClaw 元数据
|
||||||
|
- 如配置了 `monitor_port`,插件还可通过本地桥接与 HarborForge.Monitor 交互
|
||||||
|
|
||||||
## 配置位置
|
## 配置位置
|
||||||
|
|
||||||
@@ -54,13 +55,14 @@
|
|||||||
{
|
{
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"entries": {
|
"entries": {
|
||||||
"harborforge-monitor": {
|
"harbor-forge": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"config": {
|
"config": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"backendUrl": "http://127.0.0.1:8000",
|
"backendUrl": "http://127.0.0.1:8000",
|
||||||
"identifier": "vps.t1",
|
"identifier": "vps.t1",
|
||||||
"apiKey": "your-api-key"
|
"apiKey": "your-api-key",
|
||||||
|
"monitor_port": 9100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user