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:
h z
2026-03-22 14:12:43 +00:00
18 changed files with 1297 additions and 661 deletions

View File

@@ -98,29 +98,9 @@ Agent/人类协同任务管理平台 - FastAPI 后端
## CLI
```bash
# 环境变量
export HARBORFORGE_URL=http://localhost:8000
export HARBORFORGE_TOKEN=<your-token>
The legacy Python CLI (`cli.py`) has been retired. Use the Go-based `hf` CLI instead.
# 命令
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
```
See [HarborForge.Cli](../HarborForge.Cli/README.md) for installation and usage.
## 技术栈

View File

@@ -20,8 +20,8 @@ def get_user_role(db: Session, user_id: int, project_id: int) -> Role | None:
# Check global admin
user = db.query(models.User).filter(models.User.id == user_id).first()
if user and user.is_admin:
# Return global admin role
return db.query(Role).filter(Role.is_global == True, Role.name == "superadmin").first()
# Return global admin role (name="admin")
return db.query(Role).filter(Role.is_global == True, Role.name == "admin").first()
return None

View File

@@ -1,11 +1,15 @@
"""Auth router."""
from datetime import timedelta
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.config import get_db, settings
from app.models import models
from app.models.role_permission import Permission, Role, RolePermission
from app.schemas import schemas
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"})
if not user.is_active:
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(
data={"sub": str(user.id)},
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)
async def get_me(current_user: models.User = Depends(get_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,
)

View File

@@ -50,9 +50,30 @@ def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)
return db_comment
@router.get("/tasks/{task_id}/comments", response_model=List[schemas.CommentResponse])
def list_comments(task_id: int, db: Session = Depends(get_db)):
return db.query(models.Comment).filter(models.Comment.task_id == task_id).all()
@router.get("/tasks/{task_id}/comments")
def list_comments(task_id: str, db: Session = Depends(get_db)):
"""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)

289
app/api/routers/meetings.py Normal file
View 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)

View File

@@ -18,8 +18,38 @@ from app.schemas import schemas
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):
"""Serialize milestone with JSON fields."""
"""Serialize milestone with JSON fields and code."""
return {
"id": milestone.id,
"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_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [],
"project_id": milestone.project_id,
"milestone_code": milestone.milestone_code,
"code": milestone.milestone_code,
"created_by_id": milestone.created_by_id,
"started_at": milestone.started_at,
"created_at": milestone.created_at,
@@ -38,19 +70,24 @@ def _serialize_milestone(milestone):
@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)):
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
def list_milestones(project_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
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")
milestones = db.query(Milestone).filter(Milestone.project_id == project.id).all()
return [_serialize_milestone(m) for m in milestones]
@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)):
check_project_role(db, current_user.id, project_id, min_role="mgr")
def create_milestone(project_id: str, milestone: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
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 else f"P{project_id}"
max_ms = db.query(Milestone).filter(Milestone.project_id == project_id).order_by(Milestone.id.desc()).first()
project_code = project.project_code if project.project_code else f"P{project.id}"
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
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"])
if data.get("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.commit()
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)
def get_milestone(project_id: int, milestone_id: int, 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")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
def get_milestone(project_id: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
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")
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
return _serialize_milestone(milestone)
@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)):
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
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)):
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:
raise HTTPException(status_code=404, detail="Milestone not found")
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)
def delete_milestone(project_id: int, milestone_id: int, 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")
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
def delete_milestone(project_id: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
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="admin")
db_milestone = _find_milestone(db, milestone_id, project.id)
if not db_milestone:
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
@@ -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"])
def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
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)):
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")
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone:
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"),
status=TaskStatus.PENDING,
priority=TaskPriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
project_id=project.id,
milestone_id=milestone.id,
reporter_id=current_user.id,
task_code=task_code,
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")
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)):
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
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)):
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")
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
supports = db.query(Support).filter(Support.milestone_id == milestone_id).all()
meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).all()
tasks = db.query(Task).filter(Task.milestone_id == milestone.id).all()
supports = db.query(Support).filter(Support.milestone_id == milestone.id).all()
meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone.id).all()
return {
"tasks": [{
@@ -221,13 +273,16 @@ def get_milestone_items(project_id: int, milestone_id: int, db: Session = Depend
@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)):
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
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)):
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")
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone:
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)
completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED)
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)))
return {
"milestone_id": milestone_id,
"milestone_id": milestone.id,
"milestone_code": milestone.milestone_code,
"title": milestone.title,
"total": total,
"total_tasks": total,

View File

@@ -13,7 +13,7 @@ from pydantic import BaseModel
from app.core.config import get_db
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.apikey import APIKey
from app.models.activity import ActivityLog
@@ -28,6 +28,19 @@ from app.schemas import schemas
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 ============
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"])
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)
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:
query = query.filter(MilestoneModel.status == status_filter)
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"])
def get_milestone(milestone_id: int, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
return ms
def get_milestone(milestone_id: str, db: Session = Depends(get_db)):
return _resolve_milestone(db, milestone_id)
@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)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
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 = _resolve_milestone(db, milestone_id)
ensure_can_edit_milestone(db, current_user.id, ms)
for field, value in ms_update.model_dump(exclude_unset=True).items():
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"])
def delete_milestone(milestone_id: int, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
def delete_milestone(milestone_id: str, db: Session = Depends(get_db)):
ms = _resolve_milestone(db, milestone_id)
db.delete(ms)
db.commit()
return None
@router.get("/milestones/{milestone_id}/progress", tags=["Milestones"])
def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
def milestone_progress(milestone_id: str, db: Session = Depends(get_db)):
ms = _resolve_milestone(db, milestone_id)
tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
total = len(tasks)
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)))
return {
"milestone_id": milestone_id,
"milestone_id": ms.id,
"title": ms.title,
"total": 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"])
def list_task_worklogs(task_id: int, db: Session = Depends(get_db)):
return db.query(WorkLog).filter(WorkLog.task_id == task_id).order_by(WorkLog.logged_date.desc()).all()
def list_task_worklogs(task_id: str, db: Session = Depends(get_db)):
"""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"])
def task_worklog_summary(task_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
def task_worklog_summary(task_id: str, db: Session = Depends(get_db)):
"""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:
raise HTTPException(status_code=404, detail="Task not found")
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == task_id).scalar() or 0
count = db.query(WorkLog).filter(WorkLog.task_id == task_id).count()
return {"task_id": task_id, "total_hours": round(total, 2), "log_count": count}
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == tid).scalar() or 0
count = db.query(WorkLog).filter(WorkLog.task_id == tid).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"])
@@ -472,26 +515,87 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
# ============ 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"])
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()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
supports = db.query(Support).filter(
Support.project_id == project.id,
Support.milestone_id == milestone_id
).all()
return [{
"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]
return [_serialize_support(db, s) for s in 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()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, "value") and ms.status.value == "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}"
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
support_code = f"{milestone_code}:S{next_num:05x}"
support = Support(
title=support_data.get("title"),
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.commit()
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 ============

View File

@@ -15,6 +15,19 @@ from app.api.rbac import check_project_role, check_permission, ensure_can_edit_p
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:
if not codes:
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()
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)
def get_project(project_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
def get_project(project_id: str, db: Session = Depends(get_db)):
return _resolve_project(db, project_id)
@router.patch("/{project_id}", response_model=schemas.ProjectResponse)
def update_project(
project_id: int,
project_id: str,
project_update: schemas.ProjectUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project = _resolve_project(db, project_id)
ensure_can_edit_project(db, current_user.id, project)
update_data = project_update.model_dump(exclude_unset=True)
update_data.pop("name", None)
@@ -220,21 +240,20 @@ def update_project(
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_project(
project_id: int,
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="admin")
project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project = _resolve_project(db, project_id)
check_project_role(db, current_user.id, project.id, min_role="admin")
project_code = project.project_code
project_id_val = project.id
# Delete milestones and their tasks
from app.models.milestone import Milestone
from app.models.task import Task
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
milestones = db.query(Milestone).filter(Milestone.project_id == project_id_val).all()
for ms in milestones:
tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
for task in tasks:
@@ -242,7 +261,7 @@ def delete_project(
db.delete(ms)
# 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:
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)
def add_project_member(
project_id: int,
project_id: str,
member: schemas.ProjectMemberCreate,
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 = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project = _resolve_project(db, project_id)
check_project_role(db, current_user.id, project.id, min_role="mgr")
user = db.query(models.User).filter(models.User.id == member.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
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()
if existing:
raise HTTPException(status_code=400, detail="User already a member")
# Convert role name to role_id
role = db.query(Role).filter(Role.name == member.role).first()
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.commit()
db.refresh(db_member)
@@ -307,8 +324,9 @@ def add_project_member(
@router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse])
def list_project_members(project_id: int, db: Session = Depends(get_db)):
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all()
def list_project_members(project_id: str, db: Session = Depends(get_db)):
project = _resolve_project(db, project_id)
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project.id).all()
result = []
for m in members:
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()
if role:
role_name = role.name
user = db.query(models.User).filter(models.User.id == m.user_id).first()
result.append({
"id": m.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,
"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)
def remove_project_member(
project_id: int,
project_id: str,
user_id: int,
db: Session = Depends(get_db),
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(
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()
# Prevent removing project owner (admin role)
@@ -362,16 +384,18 @@ from sqlalchemy import func as sqlfunc
@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
project = _resolve_project(db, project_id)
resolved_project_id = project.id
results = db.query(
models.User.id, models.User.username,
sqlfunc.sum(WorkLog.hours).label("total_hours"),
sqlfunc.count(WorkLog.id).label("log_count")
).join(WorkLog, WorkLog.user_id == models.User.id)\
.join(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()
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]
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}

View File

@@ -17,6 +17,54 @@ from app.services.activity import log_activity
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:
"""Generate next propose code: {proj_code}:P{i:05x}"""
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])
def list_proposes(
project_id: int,
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)
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 = (
db.query(Propose)
.filter(Propose.project_id == project_id)
.filter(Propose.project_id == project.id)
.order_by(Propose.id.desc())
.all()
)
return proposes
return [_serialize_propose(db, p) for p in proposes]
@router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED)
def create_propose(
project_id: int,
project_id: str,
propose_in: schemas.ProposeCreate,
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)
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(
title=propose_in.title,
description=propose_in.description,
status=ProposeStatus.OPEN,
project_id=project_id,
project_id=project.id,
created_by_id=current_user.id,
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})
return propose
return _serialize_propose(db, propose)
@router.get("/{propose_id}", response_model=schemas.ProposeResponse)
def get_propose(
project_id: int,
propose_id: int,
project_id: str,
propose_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")
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")
check_project_role(db, current_user.id, project.id, min_role="viewer")
propose = _find_propose(db, propose_id, project.id)
if not propose:
raise HTTPException(status_code=404, detail="Propose not found")
return propose
return _serialize_propose(db, propose)
@router.patch("/{propose_id}", response_model=schemas.ProposeResponse)
def update_propose(
project_id: int,
propose_id: int,
project_id: str,
propose_id: str,
propose_in: schemas.ProposeUpdate,
db: Session = Depends(get_db),
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:
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)
return propose
return _serialize_propose(db, propose)
# ---- Actions ----
@@ -146,14 +206,17 @@ class AcceptRequest(schemas.BaseModel):
@router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse)
def accept_propose(
project_id: int,
propose_id: int,
project_id: str,
propose_id: str,
body: AcceptRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""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:
raise HTTPException(status_code=404, detail="Propose not found")
@@ -161,12 +224,12 @@ def accept_propose(
if propose_status != "open":
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
milestone = db.query(Milestone).filter(
Milestone.id == body.milestone_id,
Milestone.project_id == project_id,
Milestone.project_id == project.id,
).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found in this project")
@@ -189,7 +252,7 @@ def accept_propose(
task_subtype="feature",
status=TaskStatus.PENDING,
priority=TaskPriority.MEDIUM,
project_id=project_id,
project_id=project.id,
milestone_id=milestone.id,
reporter_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,
})
return propose
return _serialize_propose(db, propose)
class RejectRequest(schemas.BaseModel):
@@ -220,14 +283,17 @@ class RejectRequest(schemas.BaseModel):
@router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse)
def reject_propose(
project_id: int,
propose_id: int,
project_id: str,
propose_id: str,
body: RejectRequest | None = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""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:
raise HTTPException(status_code=404, detail="Propose not found")
@@ -235,7 +301,7 @@ def reject_propose(
if propose_status != "open":
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
db.commit()
@@ -245,18 +311,21 @@ def reject_propose(
"reason": body.reason if body else None,
})
return propose
return _serialize_propose(db, propose)
@router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse)
def reopen_propose(
project_id: int,
propose_id: int,
project_id: str,
propose_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""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:
raise HTTPException(status_code=404, detail="Propose not found")
@@ -264,7 +333,7 @@ def reopen_propose(
if propose_status != "rejected":
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
db.commit()
@@ -272,4 +341,4 @@ def reopen_propose(
log_activity(db, "reopen", "propose", propose.id, user_id=current_user.id)
return propose
return _serialize_propose(db, propose)

View File

@@ -2,7 +2,7 @@
import math
from typing import List, Optional
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 pydantic import BaseModel
@@ -20,6 +20,19 @@ from app.services.dependency_check import check_task_deps
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) ----
VALID_TRANSITIONS: dict[str, set[str]] = {
"pending": {"open", "closed"},
@@ -88,27 +101,100 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti
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 ----
@router.post("/tasks", response_model=schemas.TaskResponse, status_code=status.HTTP_201_CREATED)
def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
_validate_task_type_subtype(task_in.task_type, task_in.task_subtype)
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)
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["created_by_id"] = current_user.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"):
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")
milestone = db.query(Milestone).filter(
Milestone.id == data["milestone_id"],
Milestone.project_id == data["project_id"],
).first()
if not milestone:
milestone = db.query(Milestone).filter(
Milestone.id == data["milestone_id"],
Milestone.project_id == data["project_id"],
).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
@@ -139,7 +225,7 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
db,
)
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")
@@ -148,27 +234,51 @@ def list_tasks(
assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc",
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)
):
query = db.query(Task)
if project_id:
query = query.filter(Task.project_id == project_id)
if task_status:
query = query.filter(Task.status == task_status)
resolved_project_id = _resolve_project_id(db, project_id, project)
if resolved_project_id:
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:
query = query.filter(Task.task_type == task_type)
if task_subtype:
query = query.filter(Task.task_subtype == task_subtype)
if assignee_id:
query = query.filter(Task.assignee_id == assignee_id)
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:
query = query.filter(Task.tags.contains(tag))
effective_sort_by = order_by or sort_by
sort_fields = {
"created_at": Task.created_at, "updated_at": Task.updated_at,
"priority": Task.priority, "title": Task.title,
"created": Task.created_at,
"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())
total = query.count()
@@ -177,7 +287,7 @@ def list_tasks(
total_pages = math.ceil(total / page_size) if total else 1
items = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"items": [schemas.TaskResponse.model_validate(i) for i in items],
"items": [_serialize_task(db, i) for i in items],
"total": total,
"total_tasks": total,
"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)
def get_task(task_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
def get_task(task_id: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
return _serialize_task(db, task)
@router.patch("/tasks/{task_id}", response_model=schemas.TaskResponse)
def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
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 = _resolve_task(db, task_id)
# P5.7: status-based edit restrictions
current_status = task.status.value if hasattr(task.status, 'value') else task.status
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)
_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
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)
def delete_task(task_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
def delete_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = _resolve_task(db, task_id)
check_project_role(db, current_user.id, task.project_id, min_role="mgr")
log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title})
db.delete(task)
@@ -286,24 +423,24 @@ def delete_task(task_id: int, db: Session = Depends(get_db), current_user: model
# ---- Transition ----
class TransitionBody(BaseModel):
status: Optional[str] = None
comment: Optional[str] = None
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse)
def transition_task(
task_id: int,
new_status: str,
task_id: str,
bg: BackgroundTasks,
new_status: str | None = None,
body: TransitionBody = None,
db: Session = Depends(get_db),
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]
if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
task = _resolve_task(db, task_id)
old_status = task.status.value if hasattr(task.status, 'value') else task.status
# P5.1: enforce state-machine
@@ -385,16 +522,47 @@ def transition_task(
bg.add_task(fire_webhooks_sync, event,
{"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status},
task.project_id, db)
return task
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 ----
@router.post("/tasks/{task_id}/assign")
def assign_task(task_id: int, assignee_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
def assign_task(task_id: str, assignee_id: int, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user:
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 ----
@router.post("/tasks/{task_id}/tags")
def add_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
def add_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
current = set(task.tags.split(",")) if task.tags else set()
current.add(tag.strip())
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")
def remove_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
def remove_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
task = _resolve_task(db, task_id)
current = set(task.tags.split(",")) if task.tags else set()
current.discard(tag.strip())
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
items = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"items": [schemas.TaskResponse.model_validate(i) for i in items],
"items": [_serialize_task(db, i) for i in items],
"total": total,
"total_tasks": total,
"page": page,

View File

@@ -102,31 +102,40 @@ def list_users(
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(
user_id: int,
identifier: str,
db: Session = Depends(get_db),
_: 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:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.patch("/{user_id}", response_model=schemas.UserResponse)
@router.patch("/{identifier}", response_model=schemas.UserResponse)
def update_user(
user_id: int,
identifier: str,
payload: schemas.UserUpdate,
db: Session = Depends(get_db),
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:
raise HTTPException(status_code=404, detail="User not found")
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:
raise HTTPException(status_code=400, detail="Email already exists")
user.email = payload.email
@@ -153,17 +162,22 @@ def update_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(
user_id: int,
identifier: str,
db: Session = Depends(get_db),
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:
raise HTTPException(status_code=404, detail="User not found")
if current_user.id == user.id:
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:
db.delete(user)
db.commit()
@@ -173,6 +187,65 @@ def delete_user(
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):
id: int
task_id: int
@@ -186,13 +259,16 @@ class WorkLogResponse(BaseModel):
from_attributes = True
@router.get("/{user_id}/worklogs", response_model=List[WorkLogResponse])
@router.get("/{identifier}/worklogs", response_model=List[WorkLogResponse])
def list_user_worklogs(
user_id: int,
identifier: str,
limit: int = 50,
db: Session = Depends(get_db),
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")
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()

View File

@@ -126,6 +126,9 @@ DEFAULT_PERMISSIONS = [
("account.create", "Create HarborForge accounts", "account"),
# User management
("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.read", "View monitor", "monitor"),
("monitor.manage", "Manage monitor", "monitor"),
@@ -165,6 +168,7 @@ _MGR_PERMISSIONS = {
"task.close", "task.reopen_closed", "task.reopen_completed",
"propose.accept", "propose.reject", "propose.reopen",
"monitor.read",
"user.reset-self-apikey",
}
# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject propose
@@ -174,6 +178,7 @@ _DEV_PERMISSIONS = {
"milestone.read",
"task.close", "task.reopen_closed", "task.reopen_completed",
"monitor.read",
"user.reset-self-apikey",
}
_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)")
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:
"""Main initialization entry point. Reads config from shared volume."""
config = load_config()
@@ -267,6 +309,9 @@ def run_init(db: Session) -> None:
if admin_user:
init_admin_role(db, admin_user)
# Built-in acc-mgr user (after roles are created)
init_acc_mgr_user(db)
# Default project
project_cfg = config.get("default_project")
if project_cfg and admin_user:

View File

@@ -26,6 +26,26 @@ def health_check():
def version():
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
from app.api.routers.auth import router as auth_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.proposes import router as proposes_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(tasks_router)
@@ -52,6 +73,7 @@ app.include_router(milestones_router)
app.include_router(roles_router)
app.include_router(proposes_router)
app.include_router(milestone_actions_router)
app.include_router(meetings_router)
# Auto schema migration for lightweight deployments

View File

@@ -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.sql import func
from app.core.config import Base
@@ -35,3 +35,19 @@ class Meeting(Base):
created_at = Column(DateTime(timezone=True), server_default=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")

View File

@@ -44,9 +44,12 @@ class TaskBase(BaseModel):
class TaskCreate(TaskBase):
project_id: Optional[int] = None
project_code: Optional[str] = None
milestone_id: Optional[int] = None
milestone_code: Optional[str] = None
reporter_id: Optional[int] = None
assignee_id: Optional[int] = None
type: Optional[TaskTypeEnum] = None
# Resolution specific
resolution_summary: Optional[str] = None
positions: Optional[str] = None
@@ -57,10 +60,12 @@ class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
task_type: Optional[TaskTypeEnum] = None
type: Optional[TaskTypeEnum] = None
task_subtype: Optional[str] = None
status: Optional[TaskStatusEnum] = None
priority: Optional[TaskPriorityEnum] = None
assignee_id: Optional[int] = None
taken_by: Optional[str] = None
tags: Optional[str] = None
estimated_effort: Optional[int] = None
# Resolution specific
@@ -73,10 +78,16 @@ class TaskResponse(TaskBase):
id: int
status: TaskStatusEnum
task_code: Optional[str] = None
code: Optional[str] = None
type: Optional[str] = None
due_date: Optional[datetime] = None
project_id: int
project_code: Optional[str] = None
milestone_id: int
milestone_code: Optional[str] = None
reporter_id: int
assignee_id: Optional[int] = None
taken_by: Optional[str] = None
created_by_id: Optional[int] = None
estimated_working_time: Optional[time] = None
resolution_summary: Optional[str] = None
@@ -197,6 +208,8 @@ class ProjectMemberCreate(ProjectMemberBase):
class ProjectMemberResponse(BaseModel):
id: int
user_id: int
username: Optional[str] = None
full_name: Optional[str] = None
project_id: int
role: str = "dev"
@@ -240,6 +253,7 @@ class MilestoneUpdate(BaseModel):
class MilestoneResponse(MilestoneBase):
id: int
milestone_code: Optional[str] = None
project_id: int
created_by_id: Optional[int] = None
started_at: Optional[datetime] = None
@@ -278,6 +292,7 @@ class ProposeResponse(ProposeBase):
status: ProposeStatusEnum
project_id: int
created_by_id: Optional[int] = None
created_by_username: Optional[str] = None
feat_task_id: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime] = None

408
cli.py
View File

@@ -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()

View File

@@ -5,9 +5,10 @@
## 当前架构
- HarborForge Monitor Backend 提供服务器注册与遥测接收接口
- OpenClaw Gateway 加载 `harborforge-monitor` 插件
- 插件在 `gateway_start` 时启动 sidecar (`server/telemetry.mjs`)
- sidecar 通过 **HTTP + X-API-Key** 向 Backend 上报遥测
- OpenClaw Gateway 加载 `harbor-forge` 插件
- sidecar (`server/telemetry.mjs`) 已移除
- 插件通过 Gateway/runtime 路径直接提供 OpenClaw 元数据
- Monitor 可选通过本地 `monitor_port` 桥接读取补充信息
## 当前后端接口
@@ -39,7 +40,7 @@
## 数据语义
- `openclaw_version`: 远程服务器上的 OpenClaw 版本
- `plugin_version`: 远程服务器上的 harborforge-monitor 插件版本
- `plugin_version`: 远程服务器上的 `harbor-forge` 插件版本
## 已废弃内容
@@ -67,10 +68,10 @@ Monitor 管理页应提供:
1. 管理员在 Monitor 中注册服务器
2. 管理员为服务器生成 API Key
3. 将 API Key 写入 `~/.openclaw/openclaw.json`
4. 重启 OpenClaw Gateway
5. 插件启动 sidecar
6. sidecar 定时向 `/monitor/server/heartbeat-v2` 上报
4. 如需本地桥接补充信息,配置 `monitor_port`
5. 重启 OpenClaw Gateway
6. 插件直接参与遥测链路;若本地桥接可达,则额外提供 OpenClaw 补充元数据
## 备注
当前保留了对旧 challenge 数据表的**删除兼容清理**(仅为兼容老数据库中的遗留数据),但不再保留 challenge 功能入口运行时逻辑。
当前保留了对旧 challenge 数据表的**删除兼容清理**(仅为兼容老数据库中的遗留数据),但不再保留 challenge 功能入口、WebSocket 方案或 sidecar 运行时逻辑。

View File

@@ -38,13 +38,14 @@
## 语义
- `openclaw_version`: 远程主机上的 OpenClaw 版本
- `plugin_version`: harborforge-monitor 插件版本
- `plugin_version`: `harbor-forge` 插件版本
## 插件生命周期
- 插件注册到 Gateway
- `gateway_start` 启动 `server/telemetry.mjs`
- `gateway_stop` 停止 sidecar
- 插件注册名为 `harbor-forge`
- 不再启动独立 `server/telemetry.mjs` sidecar
- 插件直接通过 Gateway/runtime 路径暴露 OpenClaw 元数据
- 如配置了 `monitor_port`,插件还可通过本地桥接与 HarborForge.Monitor 交互
## 配置位置
@@ -54,13 +55,14 @@
{
"plugins": {
"entries": {
"harborforge-monitor": {
"harbor-forge": {
"enabled": true,
"config": {
"enabled": true,
"backendUrl": "http://127.0.0.1:8000",
"identifier": "vps.t1",
"apiKey": "your-api-key"
"apiKey": "your-api-key",
"monitor_port": 9100
}
}
}