from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship 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): """Proposal model — a suggested scope of work under a Project. After BE-PR-001 rename: Python class is ``Proposal``, DB table stays ``proposes`` for backward compatibility. Relationships ------------- - ``project_id`` — FK to ``projects.id``; every Proposal belongs to exactly one Project. - ``created_by_id`` — FK to ``users.id``; the user who authored the Proposal. Nullable for legacy rows created before tracking was added. - ``feat_task_id`` — **DEPRECATED (BE-PR-010)**. Previously stored the single generated ``story/feature`` task id on old-style accept. Superseded by the Essential → story-task mapping via ``Task.source_proposal_id`` / ``Task.source_essential_id`` (see BE-PR-008). **Compat strategy:** - DB column is RETAINED for read-only backward compatibility. - Existing rows that have a value will continue to expose it via API responses (read-only). - New code MUST NOT write to this field. - Clients SHOULD migrate to ``generated_tasks`` on the Proposal detail endpoint. - Column will be dropped in a future migration once all clients have migrated. """ __tablename__ = "proposes" # keep DB table name for compat id = Column(Integer, primary_key=True, index=True) # DB column stays ``propose_code`` for migration safety; use the # ``proposal_code`` hybrid property in new Python code. propose_code = Column( String(64), nullable=True, unique=True, index=True, comment="Unique human-readable code, e.g. PROJ:P00001", ) title = Column(String(255), nullable=False, comment="Short title of the proposal") description = Column(Text, nullable=True, comment="Detailed description / rationale") status = Column( Enum(ProposalStatus, values_callable=lambda x: [e.value for e in x]), default=ProposalStatus.OPEN, comment="Lifecycle status: open → accepted | rejected", ) project_id = Column( Integer, ForeignKey("projects.id"), nullable=False, comment="Owning project", ) created_by_id = Column( Integer, ForeignKey("users.id"), nullable=True, comment="Author of the proposal (nullable for legacy rows)", ) # DEPRECATED (BE-PR-010) — see class docstring for full compat strategy. # Read-only; column retained for backward compat with legacy rows. # New accept flow writes Task.source_proposal_id instead. # Will be dropped in a future schema migration. feat_task_id = Column( String(64), nullable=True, comment="DEPRECATED (BE-PR-010): legacy single story/feature task id. " "Superseded by Task.source_proposal_id. Read-only; do not write.", ) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # ---- relationships ----------------------------------------------------- essentials = relationship( "Essential", foreign_keys="Essential.proposal_id", cascade="all, delete-orphan", lazy="select", ) # BE-PR-008: reverse lookup — story tasks generated from this Proposal generated_tasks = relationship( "Task", foreign_keys="Task.source_proposal_id", lazy="select", viewonly=True, ) # ---- convenience alias ------------------------------------------------ @hybrid_property def proposal_code(self) -> str | None: """Preferred accessor — maps to the DB column ``propose_code``.""" return self.propose_code @proposal_code.setter # type: ignore[no-redef] def proposal_code(self, value: str | None) -> None: self.propose_code = value # Backward-compatible aliases ProposeStatus = ProposalStatus Propose = Proposal