Compare commits

...

1 Commits

Author SHA1 Message Date
zhi
cfacd432f5 BE-PR-001: Rename Propose -> Proposal across backend
- New canonical model: Proposal, ProposalStatus (app/models/proposal.py)
- New canonical router: /projects/{id}/proposals (app/api/routers/proposals.py)
- Schemas renamed: ProposalCreate, ProposalUpdate, ProposalResponse, etc.
- Old propose.py and proposes.py kept as backward-compat shims
- Legacy /proposes API still works (delegates to /proposals handlers)
- DB table name (proposes), column (propose_code), and permission names
  (propose.*) kept unchanged for zero-migration compat
- Updated init_wizard.py comments
2026-03-29 15:35:23 +00:00
7 changed files with 457 additions and 322 deletions

View File

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

View File

@@ -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 typing import List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func
from app.core.config import get_db from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role, check_permission, is_global_admin
from app.models import models 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.milestone import Milestone, MilestoneStatus
from app.models.task import Task, TaskStatus, TaskPriority 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 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: @router.get("", response_model=List[schemas.ProposalResponse])
"""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])
def list_proposes( def list_proposes(
project_id: str, project_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
project = _find_project(db, project_id) from app.api.routers.proposals import list_proposals
if not project: return list_proposals(project_id=project_id, db=db, current_user=current_user)
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]
@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( def create_propose(
project_id: str, project_id: str,
propose_in: schemas.ProposeCreate, proposal_in: schemas.ProposalCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
project = _find_project(db, project_id) from app.api.routers.proposals import create_proposal
if not project: return create_proposal(project_id=project_id, proposal_in=proposal_in, db=db, current_user=current_user)
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)
@router.get("/{propose_id}", response_model=schemas.ProposeResponse) @router.get("/{propose_id}", response_model=schemas.ProposalResponse)
def get_propose( def get_propose(
project_id: str, project_id: str,
propose_id: str, propose_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
project = _find_project(db, project_id) from app.api.routers.proposals import get_proposal
if not project: return get_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user)
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)
@router.patch("/{propose_id}", response_model=schemas.ProposeResponse) @router.patch("/{propose_id}", response_model=schemas.ProposalResponse)
def update_propose( def update_propose(
project_id: str, project_id: str,
propose_id: str, propose_id: str,
propose_in: schemas.ProposeUpdate, proposal_in: schemas.ProposalUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
project = _find_project(db, project_id) from app.api.routers.proposals import update_proposal
if not project: return update_proposal(project_id=project_id, proposal_id=propose_id, proposal_in=proposal_in, db=db, current_user=current_user)
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)
# ---- Actions ---- @router.post("/{propose_id}/accept", response_model=schemas.ProposalResponse)
class AcceptRequest(schemas.BaseModel):
milestone_id: int
@router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse)
def accept_propose( def accept_propose(
project_id: str, project_id: str,
propose_id: str, propose_id: str,
@@ -212,76 +83,11 @@ def accept_propose(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Accept a propose: create a feature story task in the chosen milestone.""" from app.api.routers.proposals import accept_proposal
project = _find_project(db, project_id) return accept_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user)
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)
class RejectRequest(schemas.BaseModel): @router.post("/{propose_id}/reject", response_model=schemas.ProposalResponse)
reason: str | None = None
@router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse)
def reject_propose( def reject_propose(
project_id: str, project_id: str,
propose_id: str, propose_id: str,
@@ -289,56 +95,16 @@ def reject_propose(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Reject a propose.""" from app.api.routers.proposals import reject_proposal
project = _find_project(db, project_id) return reject_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user)
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)
@router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse) @router.post("/{propose_id}/reopen", response_model=schemas.ProposalResponse)
def reopen_propose( def reopen_propose(
project_id: str, project_id: str,
propose_id: str, propose_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Reopen a rejected propose back to open.""" from app.api.routers.proposals import reopen_proposal
project = _find_project(db, project_id) return reopen_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user)
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)

View File

@@ -117,10 +117,10 @@ DEFAULT_PERMISSIONS = [
("task.close", "Close / cancel a task", "task"), ("task.close", "Close / cancel a task", "task"),
("task.reopen_closed", "Reopen a closed task", "task"), ("task.reopen_closed", "Reopen a closed task", "task"),
("task.reopen_completed", "Reopen a completed task", "task"), ("task.reopen_completed", "Reopen a completed task", "task"),
# Propose actions # Proposal actions (permission names kept as propose.* for DB compat)
("propose.accept", "Accept a propose into a milestone", "propose"), ("propose.accept", "Accept a proposal into a milestone", "propose"),
("propose.reject", "Reject a propose", "propose"), ("propose.reject", "Reject a proposal", "propose"),
("propose.reopen", "Reopen a rejected propose", "propose"), ("propose.reopen", "Reopen a rejected proposal", "propose"),
# Role/Permission management # Role/Permission management
("role.manage", "Manage roles and permissions", "admin"), ("role.manage", "Manage roles and permissions", "admin"),
("account.create", "Create HarborForge accounts", "account"), ("account.create", "Create HarborForge accounts", "account"),
@@ -159,7 +159,7 @@ def init_default_permissions(db: Session) -> list[Permission]:
# Default role → permission mapping # Default role → permission mapping
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# mgr: project management + all milestone/task/propose actions # mgr: project management + all milestone/task/proposal actions
_MGR_PERMISSIONS = { _MGR_PERMISSIONS = {
"project.read", "project.write", "project.manage_members", "project.read", "project.write", "project.manage_members",
"task.create", "task.read", "task.write", "task.delete", "task.create", "task.read", "task.write", "task.delete",
@@ -171,7 +171,7 @@ _MGR_PERMISSIONS = {
"user.reset-self-apikey", "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 = { _DEV_PERMISSIONS = {
"project.read", "project.read",
"task.create", "task.read", "task.write", "task.create", "task.read", "task.write",

View File

@@ -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.monitor import router as monitor_router
from app.api.routers.milestones import router as milestones_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.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.milestone_actions import router as milestone_actions_router
from app.api.routers.meetings import router as meetings_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(monitor_router)
app.include_router(milestones_router) app.include_router(milestones_router)
app.include_router(roles_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(milestone_actions_router)
app.include_router(meetings_router) app.include_router(meetings_router)
@@ -291,7 +293,7 @@ def _sync_default_user_roles(db):
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
from app.core.config import Base, engine, SessionLocal from app.core.config import Base, engine, SessionLocal
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, 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) Base.metadata.create_all(bind=engine)
_migrate_schema() _migrate_schema()

34
app/models/proposal.py Normal file
View File

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

View File

@@ -1,29 +1,6 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum """Backward-compatibility shim — imports from proposal.py."""
from sqlalchemy.sql import func from app.models.proposal import Proposal, ProposalStatus # noqa: F401
from app.core.config import Base
import enum
# Legacy aliases
class ProposeStatus(str, enum.Enum): Propose = Proposal
OPEN = "open" ProposeStatus = ProposalStatus
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())

View File

@@ -264,32 +264,32 @@ class MilestoneResponse(MilestoneBase):
from_attributes = True from_attributes = True
# Propose schemas # Proposal schemas (renamed from Propose)
class ProposeStatusEnum(str, Enum): class ProposalStatusEnum(str, Enum):
OPEN = "open" OPEN = "open"
ACCEPTED = "accepted" ACCEPTED = "accepted"
REJECTED = "rejected" REJECTED = "rejected"
class ProposeBase(BaseModel): class ProposalBase(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
class ProposeCreate(ProposeBase): class ProposalCreate(ProposalBase):
project_id: Optional[int] = None project_id: Optional[int] = None
class ProposeUpdate(BaseModel): class ProposalUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
class ProposeResponse(ProposeBase): class ProposalResponse(ProposalBase):
id: int id: int
propose_code: Optional[str] = None propose_code: Optional[str] = None # DB column name kept for compat
status: ProposeStatusEnum status: ProposalStatusEnum
project_id: int project_id: int
created_by_id: Optional[int] = None created_by_id: Optional[int] = None
created_by_username: Optional[str] = None created_by_username: Optional[str] = None
@@ -301,6 +301,14 @@ class ProposeResponse(ProposeBase):
from_attributes = True from_attributes = True
# Backward-compatible aliases
ProposeStatusEnum = ProposalStatusEnum
ProposeBase = ProposalBase
ProposeCreate = ProposalCreate
ProposeUpdate = ProposalUpdate
ProposeResponse = ProposalResponse
# Paginated response # Paginated response
from typing import Generic, TypeVar from typing import Generic, TypeVar
T = TypeVar("T") T = TypeVar("T")