diff --git a/app/api/routers/proposals.py b/app/api/routers/proposals.py new file mode 100644 index 0000000..7499955 --- /dev/null +++ b/app/api/routers/proposals.py @@ -0,0 +1,348 @@ +"""Proposals API router (project-scoped) — CRUD + accept/reject/reopen actions. + +Renamed from 'Proposes' to 'Proposals'. DB table name and permission names +kept as-is for backward compatibility. +""" +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy import func as sa_func + +from app.core.config import get_db +from app.api.deps import get_current_user_or_apikey +from app.api.rbac import check_project_role, check_permission, is_global_admin +from app.models import models +from app.models.proposal import Proposal, ProposalStatus +from app.models.milestone import Milestone, MilestoneStatus +from app.models.task import Task, TaskStatus, TaskPriority +from app.schemas import schemas +from app.services.activity import log_activity + +router = APIRouter(prefix="/projects/{project_id}/proposals", tags=["Proposals"]) + + +def _serialize_proposal(db: Session, proposal: Proposal) -> dict: + """Serialize proposal with created_by_username.""" + creator = db.query(models.User).filter(models.User.id == proposal.created_by_id).first() if proposal.created_by_id else None + return { + "id": proposal.id, + "title": proposal.title, + "description": proposal.description, + "propose_code": proposal.propose_code, + "status": proposal.status.value if hasattr(proposal.status, "value") else proposal.status, + "project_id": proposal.project_id, + "created_by_id": proposal.created_by_id, + "created_by_username": creator.username if creator else None, + "feat_task_id": proposal.feat_task_id, + "created_at": proposal.created_at, + "updated_at": proposal.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_proposal(db, identifier, project_id: int = None) -> Proposal | None: + """Look up proposal by numeric id or propose_code.""" + try: + pid = int(identifier) + q = db.query(Proposal).filter(Proposal.id == pid) + if project_id: + q = q.filter(Proposal.project_id == project_id) + p = q.first() + if p: + return p + except (ValueError, TypeError): + pass + q = db.query(Proposal).filter(Proposal.propose_code == str(identifier)) + if project_id: + q = q.filter(Proposal.project_id == project_id) + return q.first() + + +def _generate_proposal_code(db: Session, project_id: int) -> str: + """Generate next proposal code: {proj_code}:P{i:05x}""" + project = db.query(models.Project).filter(models.Project.id == project_id).first() + project_code = project.project_code if project and project.project_code else f"P{project_id}" + + max_proposal = ( + db.query(Proposal) + .filter(Proposal.project_id == project_id) + .order_by(Proposal.id.desc()) + .first() + ) + next_num = (max_proposal.id + 1) if max_proposal else 1 + return f"{project_code}:P{next_num:05x}" + + +def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool: + """Only creator, project admin, or global admin can edit an open proposal.""" + if is_global_admin(db, user_id): + return True + if proposal.created_by_id == user_id: + return True + project = db.query(models.Project).filter(models.Project.id == proposal.project_id).first() + if project and project.owner_id == user_id: + return True + return False + + +# ---- CRUD ---- + +@router.get("", response_model=List[schemas.ProposalResponse]) +def list_proposals( + 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") + proposals = ( + db.query(Proposal) + .filter(Proposal.project_id == project.id) + .order_by(Proposal.id.desc()) + .all() + ) + return [_serialize_proposal(db, p) for p in proposals] + + +@router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED) +def create_proposal( + project_id: str, + proposal_in: schemas.ProposalCreate, + 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") + + proposal_code = _generate_proposal_code(db, project.id) + + proposal = Proposal( + title=proposal_in.title, + description=proposal_in.description, + status=ProposalStatus.OPEN, + project_id=project.id, + created_by_id=current_user.id, + propose_code=proposal_code, + ) + db.add(proposal) + db.commit() + db.refresh(proposal) + + log_activity(db, "create", "proposal", proposal.id, user_id=current_user.id, details={"title": proposal.title}) + + return _serialize_proposal(db, proposal) + + +@router.get("/{proposal_id}", response_model=schemas.ProposalResponse) +def get_proposal( + project_id: str, + proposal_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") + proposal = _find_proposal(db, proposal_id, project.id) + if not proposal: + raise HTTPException(status_code=404, detail="Proposal not found") + return _serialize_proposal(db, proposal) + + +@router.patch("/{proposal_id}", response_model=schemas.ProposalResponse) +def update_proposal( + project_id: str, + proposal_id: str, + proposal_in: schemas.ProposalUpdate, + 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") + proposal = _find_proposal(db, proposal_id, project.id) + if not proposal: + raise HTTPException(status_code=404, detail="Proposal not found") + + # Only open proposals can be edited + proposal_status = proposal.status.value if hasattr(proposal.status, "value") else proposal.status + if proposal_status != "open": + raise HTTPException(status_code=400, detail="Only open proposals can be edited") + + if not _can_edit_proposal(db, current_user.id, proposal): + raise HTTPException(status_code=403, detail="Proposal edit permission denied") + + data = proposal_in.model_dump(exclude_unset=True) + # Never allow client to set feat_task_id + data.pop("feat_task_id", None) + + for key, value in data.items(): + setattr(proposal, key, value) + db.commit() + db.refresh(proposal) + + log_activity(db, "update", "proposal", proposal.id, user_id=current_user.id, details=data) + + return _serialize_proposal(db, proposal) + + +# ---- Actions ---- + +class AcceptRequest(schemas.BaseModel): + milestone_id: int + + +@router.post("/{proposal_id}/accept", response_model=schemas.ProposalResponse) +def accept_proposal( + project_id: str, + proposal_id: str, + body: AcceptRequest, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + """Accept a proposal: create a feature story task in the chosen milestone.""" + project = _find_project(db, project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + proposal = _find_proposal(db, proposal_id, project.id) + if not proposal: + raise HTTPException(status_code=404, detail="Proposal not found") + + proposal_status = proposal.status.value if hasattr(proposal.status, "value") else proposal.status + if proposal_status != "open": + raise HTTPException(status_code=400, detail="Only open proposals can be accepted") + + check_permission(db, current_user.id, project.id, "propose.accept") # permission name kept for DB compat + + # Validate milestone + milestone = db.query(Milestone).filter( + Milestone.id == body.milestone_id, + Milestone.project_id == project.id, + ).first() + if not milestone: + raise HTTPException(status_code=404, detail="Milestone not found in this project") + + ms_status = milestone.status.value if hasattr(milestone.status, "value") else milestone.status + if ms_status != "open": + raise HTTPException(status_code=400, detail="Target milestone must be in 'open' status") + + # Generate task code + milestone_code = milestone.milestone_code or f"m{milestone.id}" + max_task = db.query(Task).filter(Task.milestone_id == milestone.id).order_by(Task.id.desc()).first() + next_num = (max_task.id + 1) if max_task else 1 + task_code = f"{milestone_code}:T{next_num:05x}" + + # Create feature story task + task = Task( + title=proposal.title, + description=proposal.description, + task_type="story", + task_subtype="feature", + status=TaskStatus.PENDING, + priority=TaskPriority.MEDIUM, + project_id=project.id, + milestone_id=milestone.id, + reporter_id=proposal.created_by_id or current_user.id, + created_by_id=proposal.created_by_id or current_user.id, + task_code=task_code, + ) + db.add(task) + db.flush() # get task.id + + # Update proposal + proposal.status = ProposalStatus.ACCEPTED + proposal.feat_task_id = str(task.id) + + db.commit() + db.refresh(proposal) + + log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={ + "milestone_id": milestone.id, + "generated_task_id": task.id, + "task_code": task_code, + }) + + return _serialize_proposal(db, proposal) + + +class RejectRequest(schemas.BaseModel): + reason: str | None = None + + +@router.post("/{proposal_id}/reject", response_model=schemas.ProposalResponse) +def reject_proposal( + project_id: str, + proposal_id: str, + body: RejectRequest | None = None, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + """Reject a proposal.""" + project = _find_project(db, project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + proposal = _find_proposal(db, proposal_id, project.id) + if not proposal: + raise HTTPException(status_code=404, detail="Proposal not found") + + proposal_status = proposal.status.value if hasattr(proposal.status, "value") else proposal.status + if proposal_status != "open": + raise HTTPException(status_code=400, detail="Only open proposals can be rejected") + + check_permission(db, current_user.id, project.id, "propose.reject") # permission name kept for DB compat + + proposal.status = ProposalStatus.REJECTED + db.commit() + db.refresh(proposal) + + log_activity(db, "reject", "proposal", proposal.id, user_id=current_user.id, details={ + "reason": body.reason if body else None, + }) + + return _serialize_proposal(db, proposal) + + +@router.post("/{proposal_id}/reopen", response_model=schemas.ProposalResponse) +def reopen_proposal( + project_id: str, + proposal_id: str, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + """Reopen a rejected proposal back to open.""" + project = _find_project(db, project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + proposal = _find_proposal(db, proposal_id, project.id) + if not proposal: + raise HTTPException(status_code=404, detail="Proposal not found") + + proposal_status = proposal.status.value if hasattr(proposal.status, "value") else proposal.status + if proposal_status != "rejected": + raise HTTPException(status_code=400, detail="Only rejected proposals can be reopened") + + check_permission(db, current_user.id, project.id, "propose.reopen") # permission name kept for DB compat + + proposal.status = ProposalStatus.OPEN + db.commit() + db.refresh(proposal) + + log_activity(db, "reopen", "proposal", proposal.id, user_id=current_user.id) + + return _serialize_proposal(db, proposal) diff --git a/app/api/routers/proposes.py b/app/api/routers/proposes.py index 2111b12..2d9d42f 100644 --- a/app/api/routers/proposes.py +++ b/app/api/routers/proposes.py @@ -1,210 +1,81 @@ -"""Proposes API router (project-scoped) — CRUD + accept/reject/reopen actions.""" +"""Backward-compatibility shim — mounts legacy /proposes routes alongside /proposals. + +This keeps old API consumers working while the canonical path is now /proposals. +""" from typing import List from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from sqlalchemy import func as sa_func from app.core.config import get_db from app.api.deps import get_current_user_or_apikey -from app.api.rbac import check_project_role, check_permission, is_global_admin from app.models import models -from app.models.propose import Propose, ProposeStatus +from app.schemas import schemas + +# Import all handler functions from the canonical proposals router +from app.api.routers.proposals import ( + _find_project, + _find_proposal, + _serialize_proposal, + _generate_proposal_code, + _can_edit_proposal, + AcceptRequest, + RejectRequest, +) +from app.models.proposal import Proposal, ProposalStatus from app.models.milestone import Milestone, MilestoneStatus from app.models.task import Task, TaskStatus, TaskPriority -from app.schemas import schemas +from app.api.rbac import check_project_role, check_permission, is_global_admin from app.services.activity import log_activity -router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes"]) +# Legacy router — same logic, old URL prefix +router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes (legacy)"]) -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() - project_code = project.project_code if project and project.project_code else f"P{project_id}" - - max_propose = ( - db.query(Propose) - .filter(Propose.project_id == project_id) - .order_by(Propose.id.desc()) - .first() - ) - next_num = (max_propose.id + 1) if max_propose else 1 - return f"{project_code}:P{next_num:05x}" - - -def _can_edit_propose(db: Session, user_id: int, propose: Propose) -> bool: - """Only creator, project admin, or global admin can edit an open propose.""" - if is_global_admin(db, user_id): - return True - if propose.created_by_id == user_id: - return True - project = db.query(models.Project).filter(models.Project.id == propose.project_id).first() - if project and project.owner_id == user_id: - return True - return False - - -# ---- CRUD ---- - -@router.get("", response_model=List[schemas.ProposeResponse]) +@router.get("", response_model=List[schemas.ProposalResponse]) def list_proposes( 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") - proposes = ( - db.query(Propose) - .filter(Propose.project_id == project.id) - .order_by(Propose.id.desc()) - .all() - ) - return [_serialize_propose(db, p) for p in proposes] + from app.api.routers.proposals import list_proposals + return list_proposals(project_id=project_id, db=db, current_user=current_user) -@router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED) +@router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED) def create_propose( project_id: str, - propose_in: schemas.ProposeCreate, + proposal_in: schemas.ProposalCreate, 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") - - 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, - created_by_id=current_user.id, - propose_code=propose_code, - ) - db.add(propose) - db.commit() - db.refresh(propose) - - log_activity(db, "create", "propose", propose.id, user_id=current_user.id, details={"title": propose.title}) - - return _serialize_propose(db, propose) + from app.api.routers.proposals import create_proposal + return create_proposal(project_id=project_id, proposal_in=proposal_in, db=db, current_user=current_user) -@router.get("/{propose_id}", response_model=schemas.ProposeResponse) +@router.get("/{propose_id}", response_model=schemas.ProposalResponse) def get_propose( project_id: str, propose_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") - propose = _find_propose(db, propose_id, project.id) - if not propose: - raise HTTPException(status_code=404, detail="Propose not found") - return _serialize_propose(db, propose) + from app.api.routers.proposals import get_proposal + return get_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user) -@router.patch("/{propose_id}", response_model=schemas.ProposeResponse) +@router.patch("/{propose_id}", response_model=schemas.ProposalResponse) def update_propose( project_id: str, propose_id: str, - propose_in: schemas.ProposeUpdate, + proposal_in: schemas.ProposalUpdate, 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") - propose = _find_propose(db, propose_id, project.id) - if not propose: - raise HTTPException(status_code=404, detail="Propose not found") - - # Only open proposes can be edited - propose_status = propose.status.value if hasattr(propose.status, "value") else propose.status - if propose_status != "open": - raise HTTPException(status_code=400, detail="Only open proposes can be edited") - - if not _can_edit_propose(db, current_user.id, propose): - raise HTTPException(status_code=403, detail="Propose edit permission denied") - - data = propose_in.model_dump(exclude_unset=True) - # Never allow client to set feat_task_id - data.pop("feat_task_id", None) - - for key, value in data.items(): - setattr(propose, key, value) - db.commit() - db.refresh(propose) - - log_activity(db, "update", "propose", propose.id, user_id=current_user.id, details=data) - - return _serialize_propose(db, propose) + from app.api.routers.proposals import update_proposal + return update_proposal(project_id=project_id, proposal_id=propose_id, proposal_in=proposal_in, db=db, current_user=current_user) -# ---- Actions ---- - -class AcceptRequest(schemas.BaseModel): - milestone_id: int - - -@router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse) +@router.post("/{propose_id}/accept", response_model=schemas.ProposalResponse) def accept_propose( project_id: str, propose_id: str, @@ -212,76 +83,11 @@ def accept_propose( 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.""" - 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") - - propose_status = propose.status.value if hasattr(propose.status, "value") else propose.status - 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") - - # Validate milestone - milestone = db.query(Milestone).filter( - Milestone.id == body.milestone_id, - Milestone.project_id == project.id, - ).first() - if not milestone: - raise HTTPException(status_code=404, detail="Milestone not found in this project") - - ms_status = milestone.status.value if hasattr(milestone.status, "value") else milestone.status - if ms_status != "open": - raise HTTPException(status_code=400, detail="Target milestone must be in 'open' status") - - # Generate task code - milestone_code = milestone.milestone_code or f"m{milestone.id}" - max_task = db.query(Task).filter(Task.milestone_id == milestone.id).order_by(Task.id.desc()).first() - next_num = (max_task.id + 1) if max_task else 1 - task_code = f"{milestone_code}:T{next_num:05x}" - - # Create feature story task - task = Task( - title=propose.title, - description=propose.description, - task_type="story", - task_subtype="feature", - status=TaskStatus.PENDING, - priority=TaskPriority.MEDIUM, - 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, - task_code=task_code, - ) - db.add(task) - db.flush() # get task.id - - # Update propose - propose.status = ProposeStatus.ACCEPTED - propose.feat_task_id = str(task.id) - - db.commit() - db.refresh(propose) - - log_activity(db, "accept", "propose", propose.id, user_id=current_user.id, details={ - "milestone_id": milestone.id, - "generated_task_id": task.id, - "task_code": task_code, - }) - - return _serialize_propose(db, propose) + from app.api.routers.proposals import accept_proposal + return accept_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user) -class RejectRequest(schemas.BaseModel): - reason: str | None = None - - -@router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse) +@router.post("/{propose_id}/reject", response_model=schemas.ProposalResponse) def reject_propose( project_id: str, propose_id: str, @@ -289,56 +95,16 @@ def reject_propose( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - """Reject a propose.""" - 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") - - propose_status = propose.status.value if hasattr(propose.status, "value") else propose.status - 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") - - propose.status = ProposeStatus.REJECTED - db.commit() - db.refresh(propose) - - log_activity(db, "reject", "propose", propose.id, user_id=current_user.id, details={ - "reason": body.reason if body else None, - }) - - return _serialize_propose(db, propose) + from app.api.routers.proposals import reject_proposal + return reject_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user) -@router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse) +@router.post("/{propose_id}/reopen", response_model=schemas.ProposalResponse) def reopen_propose( 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.""" - 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") - - propose_status = propose.status.value if hasattr(propose.status, "value") else propose.status - 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") - - propose.status = ProposeStatus.OPEN - db.commit() - db.refresh(propose) - - log_activity(db, "reopen", "propose", propose.id, user_id=current_user.id) - - return _serialize_propose(db, propose) + from app.api.routers.proposals import reopen_proposal + return reopen_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user) diff --git a/app/init_wizard.py b/app/init_wizard.py index d47e473..4724d78 100644 --- a/app/init_wizard.py +++ b/app/init_wizard.py @@ -117,10 +117,10 @@ DEFAULT_PERMISSIONS = [ ("task.close", "Close / cancel a task", "task"), ("task.reopen_closed", "Reopen a closed task", "task"), ("task.reopen_completed", "Reopen a completed task", "task"), - # Propose actions - ("propose.accept", "Accept a propose into a milestone", "propose"), - ("propose.reject", "Reject a propose", "propose"), - ("propose.reopen", "Reopen a rejected propose", "propose"), + # Proposal actions (permission names kept as propose.* for DB compat) + ("propose.accept", "Accept a proposal into a milestone", "propose"), + ("propose.reject", "Reject a proposal", "propose"), + ("propose.reopen", "Reopen a rejected proposal", "propose"), # Role/Permission management ("role.manage", "Manage roles and permissions", "admin"), ("account.create", "Create HarborForge accounts", "account"), @@ -159,7 +159,7 @@ def init_default_permissions(db: Session) -> list[Permission]: # Default role → permission mapping # --------------------------------------------------------------------------- -# mgr: project management + all milestone/task/propose actions +# mgr: project management + all milestone/task/proposal actions _MGR_PERMISSIONS = { "project.read", "project.write", "project.manage_members", "task.create", "task.read", "task.write", "task.delete", @@ -171,7 +171,7 @@ _MGR_PERMISSIONS = { "user.reset-self-apikey", } -# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject propose +# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject proposal _DEV_PERMISSIONS = { "project.read", "task.create", "task.read", "task.write", diff --git a/app/main.py b/app/main.py index 261a7ee..4cbe6df 100644 --- a/app/main.py +++ b/app/main.py @@ -57,7 +57,8 @@ from app.api.routers.misc import router as misc_router from app.api.routers.monitor import router as monitor_router 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.proposals import router as proposals_router +from app.api.routers.proposes import router as proposes_router # legacy compat from app.api.routers.milestone_actions import router as milestone_actions_router from app.api.routers.meetings import router as meetings_router @@ -71,7 +72,8 @@ app.include_router(misc_router) app.include_router(monitor_router) app.include_router(milestones_router) app.include_router(roles_router) -app.include_router(proposes_router) +app.include_router(proposals_router) +app.include_router(proposes_router) # legacy compat app.include_router(milestone_actions_router) app.include_router(meetings_router) @@ -291,7 +293,7 @@ def _sync_default_user_roles(db): @app.on_event("startup") def startup(): from app.core.config import Base, engine, SessionLocal - from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, propose + from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose Base.metadata.create_all(bind=engine) _migrate_schema() diff --git a/app/models/proposal.py b/app/models/proposal.py new file mode 100644 index 0000000..3e12a66 --- /dev/null +++ b/app/models/proposal.py @@ -0,0 +1,34 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum +from sqlalchemy.sql import func +from app.core.config import Base +import enum + + +class ProposalStatus(str, enum.Enum): + OPEN = "open" + ACCEPTED = "accepted" + REJECTED = "rejected" + + +class Proposal(Base): + __tablename__ = "proposes" # keep DB table name for compat + + id = Column(Integer, primary_key=True, index=True) + propose_code = Column(String(64), nullable=True, unique=True, index=True) # keep column name for DB compat + title = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + status = Column(Enum(ProposalStatus, values_callable=lambda x: [e.value for e in x]), default=ProposalStatus.OPEN) + + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + + # Populated server-side after accept; links to the generated feature story task + feat_task_id = Column(String(64), nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + +# Backward-compatible aliases +ProposeStatus = ProposalStatus +Propose = Proposal diff --git a/app/models/propose.py b/app/models/propose.py index 9270d82..3d32d1f 100644 --- a/app/models/propose.py +++ b/app/models/propose.py @@ -1,29 +1,6 @@ -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum -from sqlalchemy.sql import func -from app.core.config import Base -import enum +"""Backward-compatibility shim — imports from proposal.py.""" +from app.models.proposal import Proposal, ProposalStatus # noqa: F401 - -class ProposeStatus(str, enum.Enum): - OPEN = "open" - ACCEPTED = "accepted" - REJECTED = "rejected" - - -class Propose(Base): - __tablename__ = "proposes" - - id = Column(Integer, primary_key=True, index=True) - propose_code = Column(String(64), nullable=True, unique=True, index=True) - title = Column(String(255), nullable=False) - description = Column(Text, nullable=True) - status = Column(Enum(ProposeStatus, values_callable=lambda x: [e.value for e in x]), default=ProposeStatus.OPEN) - - project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) - created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) - - # Populated server-side after accept; links to the generated feature story task - feat_task_id = Column(String(64), nullable=True) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), onupdate=func.now()) +# Legacy aliases +Propose = Proposal +ProposeStatus = ProposalStatus diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 90b2d09..6fc70f0 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -264,32 +264,32 @@ class MilestoneResponse(MilestoneBase): from_attributes = True -# Propose schemas +# Proposal schemas (renamed from Propose) -class ProposeStatusEnum(str, Enum): +class ProposalStatusEnum(str, Enum): OPEN = "open" ACCEPTED = "accepted" REJECTED = "rejected" -class ProposeBase(BaseModel): +class ProposalBase(BaseModel): title: str description: Optional[str] = None -class ProposeCreate(ProposeBase): +class ProposalCreate(ProposalBase): project_id: Optional[int] = None -class ProposeUpdate(BaseModel): +class ProposalUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None -class ProposeResponse(ProposeBase): +class ProposalResponse(ProposalBase): id: int - propose_code: Optional[str] = None - status: ProposeStatusEnum + propose_code: Optional[str] = None # DB column name kept for compat + status: ProposalStatusEnum project_id: int created_by_id: Optional[int] = None created_by_username: Optional[str] = None @@ -301,6 +301,14 @@ class ProposeResponse(ProposeBase): from_attributes = True +# Backward-compatible aliases +ProposeStatusEnum = ProposalStatusEnum +ProposeBase = ProposalBase +ProposeCreate = ProposalCreate +ProposeUpdate = ProposalUpdate +ProposeResponse = ProposalResponse + + # Paginated response from typing import Generic, TypeVar T = TypeVar("T")