Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions backend/alembic/versions/0015_review_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""review_models: Review and ReviewFinding tables

Revision ID: 0015_review_models
Revises: 0014_post_submit_setup
Create Date: 2026-07-11
"""

from __future__ import annotations

from alembic import op
import sqlalchemy as sa

revision = "0015_review_models"
down_revision = "0014_post_submit_setup"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"reviews",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("submission_id", sa.String(36), sa.ForeignKey("submissions.id"), nullable=False, index=True),
sa.Column("reviewer_actor_id", sa.String(100), sa.ForeignKey("actor_identities.actor_id"), nullable=False, index=True),
sa.Column("decision", sa.String(30), nullable=False, index=True),
sa.Column("acceptance_evidence_refs", sa.JSON, nullable=False, server_default="[]"),
sa.Column("comment", sa.Text, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.CheckConstraint("decision in ('accept', 'needs_revision', 'reject')", name="ck_reviews_decision"),
)
op.create_index("ix_reviews_submission_created", "reviews", ["submission_id", "created_at"])

op.create_table(
"review_findings",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("review_id", sa.String(36), sa.ForeignKey("reviews.id"), nullable=False, index=True),
sa.Column("severity", sa.String(20), nullable=False),
sa.Column("area", sa.String(100), nullable=False),
sa.Column("issue", sa.Text, nullable=False),
sa.Column("required_fix", sa.Text, nullable=False),
sa.Column("evidence_ref", sa.String(500), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.CheckConstraint("severity in ('low', 'medium', 'high', 'critical')", name="ck_review_findings_severity"),
)


def downgrade() -> None:
op.drop_table("review_findings")
op.drop_table("reviews")
2 changes: 2 additions & 0 deletions backend/app/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
from app.api.routes.health import router as health_router
from app.modules.checkers.router import router as checkers_router
from app.modules.projects.router import router as projects_router
from app.modules.review.router import router as reviews_router
from app.modules.tasks.router import router as tasks_router

api_router = APIRouter()
api_router.include_router(health_router)
api_router.include_router(health_router, prefix="/api/v1")
api_router.include_router(auth_router, prefix="/api/v1")
api_router.include_router(projects_router, prefix="/api/v1")
api_router.include_router(reviews_router, prefix="/api/v1")
api_router.include_router(tasks_router, prefix="/api/v1")
api_router.include_router(checkers_router, prefix="/api/v1")
1 change: 1 addition & 0 deletions backend/app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ReviewPolicy,
SubmissionArtifactPolicy,
)
from app.modules.review.models import Review, ReviewFinding # noqa: F401
from app.modules.tasks.models import ( # noqa: F401
AuditEvent,
EvidenceItem,
Expand Down
87 changes: 87 additions & 0 deletions backend/app/modules/review/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""SQLAlchemy models for review decisions and findings."""

from __future__ import annotations

from datetime import datetime

from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func

from app.db.base import Base

REVIEW_DECISION_ACCEPT = "accept"
REVIEW_DECISION_NEEDS_REVISION = "needs_revision"
REVIEW_DECISION_REJECT = "reject"
REVIEW_DECISIONS = (
REVIEW_DECISION_ACCEPT,
REVIEW_DECISION_NEEDS_REVISION,
REVIEW_DECISION_REJECT,
)
REVIEW_FINDING_SEVERITIES = ("low", "medium", "high", "critical")


class Review(Base):
"""Auditable human review decision for one immutable submission."""

__tablename__ = "reviews"
__table_args__ = (
CheckConstraint(
"decision in ('accept', 'needs_revision', 'reject')",
name="ck_reviews_decision",
),
Index("ix_reviews_submission_created", "submission_id", "created_at"),
)

id: Mapped[str] = mapped_column(String(36), primary_key=True)
submission_id: Mapped[str] = mapped_column(
ForeignKey("submissions.id"),
nullable=False,
index=True,
)
reviewer_actor_id: Mapped[str] = mapped_column(
ForeignKey("actor_identities.actor_id"),
nullable=False,
index=True,
)
decision: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
acceptance_evidence_refs: Mapped[list[str]] = mapped_column(
"acceptance_evidence_refs",
nullable=False,
default=list,
)
comment: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)

findings: Mapped[list[ReviewFinding]] = relationship(
back_populates="review",
cascade="all, delete-orphan",
)


class ReviewFinding(Base):
"""Structured reviewer finding attached to a review decision."""

__tablename__ = "review_findings"
__table_args__ = (
CheckConstraint(
"severity in ('low', 'medium', 'high', 'critical')",
name="ck_review_findings_severity",
),
)

id: Mapped[str] = mapped_column(String(36), primary_key=True)
review_id: Mapped[str] = mapped_column(ForeignKey("reviews.id"), nullable=False, index=True)
severity: Mapped[str] = mapped_column(String(20), nullable=False)
area: Mapped[str] = mapped_column(String(100), nullable=False)
issue: Mapped[str] = mapped_column(Text, nullable=False)
required_fix: Mapped[str] = mapped_column(Text, nullable=False)
evidence_ref: Mapped[str] = mapped_column(String(500), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

review: Mapped[Review] = relationship(back_populates="findings")
61 changes: 61 additions & 0 deletions backend/app/modules/review/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""FastAPI routes for review decision creation and querying."""

from __future__ import annotations

from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps.auth import get_registered_actor
from app.db.session import get_db_session
from app.modules.review.schemas import ReviewCreate, ReviewSchema
from app.modules.review.service import ReviewError, ReviewService, ReviewValidationError
from app.schemas.auth import ActorContext

router = APIRouter(prefix="/reviews", tags=["reviews"])


@router.post("", response_model=ReviewSchema, status_code=status.HTTP_201_CREATED)
async def create_review(
payload: ReviewCreate,
actor: Annotated[ActorContext, Depends(get_registered_actor)],
session: Annotated[AsyncSession, Depends(get_db_session)],
) -> ReviewSchema:
"""Submit a human review decision with structured findings.

Enforces:
- Only accept, needs_revision, reject.
- reject / needs_revision require at least one finding.
- accept requires evidence references and no blocking checker results.
"""
try:
return await ReviewService(session).create_review(payload, actor.actor_id)
except ReviewValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
except ReviewError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))


@router.get("/{submission_id}", response_model=list[ReviewSchema])
async def list_reviews_for_submission(
submission_id: str,
actor: Annotated[ActorContext, Depends(get_registered_actor)],
session: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[ReviewSchema]:
"""Return all review decisions for a submission, newest first."""
reviews = await ReviewService(session).get_reviews_for_submission(submission_id)
return list(reviews)


@router.get("/review/{review_id}", response_model=ReviewSchema)
async def get_review(
review_id: str,
actor: Annotated[ActorContext, Depends(get_registered_actor)],
session: Annotated[AsyncSession, Depends(get_db_session)],
) -> ReviewSchema:
"""Return one review by its id."""
review = await ReviewService(session).get_review(review_id)
if not review:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Review not found")
return review
57 changes: 57 additions & 0 deletions backend/app/modules/review/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Pydantic schemas for review decisions and findings."""

from __future__ import annotations

from datetime import datetime

from pydantic import BaseModel, Field, ConfigDict

from app.modules.review.models import (
REVIEW_DECISION_ACCEPT,
REVIEW_DECISION_NEEDS_REVISION,
REVIEW_DECISION_REJECT,
REVIEW_DECISIONS,
REVIEW_FINDING_SEVERITIES,
)

class ReviewFindingSchema(BaseModel):
"""Schema for a single review finding."""
model_config = ConfigDict(from_attributes=True)

id: str
severity: str = Field(..., description="Severity of the finding (low, medium, high, critical)")
area: str = Field(..., description="The functional area the finding relates to")
issue: str = Field(..., description="Detailed description of the issue")
required_fix: str = Field(..., description="What must be fixed to resolve this finding")
evidence_ref: str = Field(..., description="Reference to evidence or file location")
created_at: datetime

class ReviewSchema(BaseModel):
"""Schema for a complete review decision."""
model_config = ConfigDict(from_attributes=True)

id: str
submission_id: str
reviewer_actor_id: str
decision: str
acceptance_evidence_refs: list[str] = Field(default_factory=list)
comment: str | None = None
findings: list[ReviewFindingSchema] = []
created_at: datetime
updated_at: datetime

class ReviewFindingCreate(BaseModel):
"""Input schema for creating a finding."""
severity: str = Field(..., pattern="^(low|medium|high|critical)$")
area: str = Field(..., min_length=1, max_length=100)
issue: str = Field(..., min_length=1)
required_fix: str = Field(..., min_length=1)
evidence_ref: str = Field(..., min_length=1)

class ReviewCreate(BaseModel):
"""Input schema for creating a review."""
submission_id: str = Field(..., min_length=1)
decision: str = Field(..., pattern="^(accept|needs_revision|reject)$")
acceptance_evidence_refs: list[str] = Field(default_factory=list)
comment: str | None = None
findings: list[ReviewFindingCreate] = Field(default_factory=list)
Loading