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
This commit is contained in:
348
app/api/routers/proposals.py
Normal file
348
app/api/routers/proposals.py
Normal 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)
|
||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
34
app/models/proposal.py
Normal 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
|
||||||
@@ -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())
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user