Skip to content
Merged
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
38 changes: 38 additions & 0 deletions backend/alembic/versions/n3o4p5q6r7s8_add_projects_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""add projects table

Revision ID: n3o4p5q6r7s8
Revises: m2n3o4p5q6r7
Create Date: 2026-05-08 19:10:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "n3o4p5q6r7s8"
down_revision: Union[str, None] = "m2n3o4p5q6r7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"projects",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_projects_name", "projects", ["name"], unique=False)


def downgrade() -> None:
op.drop_index("ix_projects_name", table_name="projects")
op.drop_table("projects")
8 changes: 7 additions & 1 deletion backend/app/api/external_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,13 @@ async def create_external_session(
normalized_payload = payload.model_copy(
update={"runner": payload.runner if payload.runner is not None else _DEFAULT_RUNNER}
)
session = await create_session(db, payload=normalized_payload, runner_token_id=token.id)
try:
session = await create_session(db, payload=normalized_payload, runner_token_id=token.id)
except ValueError as exc:
detail = exc.args[0]
if isinstance(detail, dict) and detail.get("code") == "session.project_not_found":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc
raise
await write_audit(
db,
actor_kind="runner_token",
Expand Down
102 changes: 102 additions & 0 deletions backend/app/api/projects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""API endpoints for Projects."""

import math
from typing import Any
from uuid import UUID

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

from app.auth.dependencies import get_current_user, require_reviewer_or_admin
from app.crud import project as crud
from app.crud.audit_log import write_audit
from app.db.session import get_db
from app.models.user import User
from app.schemas.pagination import PaginatedResponse
from app.schemas.project import ProjectCreate, ProjectResponse, ProjectUpdate

router = APIRouter()


@router.post("/projects", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(
payload: ProjectCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reviewer_or_admin),
) -> ProjectResponse:
project = await crud.create_project(db, payload)
await write_audit(
db,
actor_kind="user",
actor_id=current_user.id,
action="project.create",
resource_type="project",
resource_id=project.id,
details=payload.model_dump(),
)
return project


@router.get("/projects", response_model=PaginatedResponse[ProjectResponse])
async def list_projects(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(get_current_user),
) -> PaginatedResponse[ProjectResponse]:
skip = (page - 1) * page_size
items, total = await crud.list_projects(db, skip=skip, limit=page_size)
return PaginatedResponse(
items=items,
total=total,
page=page,
page_size=page_size,
pages=math.ceil(total / page_size) if total > 0 else 0,
)


@router.get("/projects/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: UUID,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(get_current_user),
) -> ProjectResponse:
project = await crud.get_project(db, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found")
return project


@router.patch("/projects/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: UUID,
payload: ProjectUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_reviewer_or_admin),
) -> ProjectResponse:
existing = await crud.get_project(db, project_id)
if existing is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found")

changed_fields = payload.model_dump(exclude_unset=True)
original_values = {field: getattr(existing, field) for field in changed_fields}
updated = await crud.update_project(db, project_id, payload)
if updated is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found")

diff: dict[str, dict[str, Any]] = {}
for field, new_value in changed_fields.items():
old_value = original_values[field]
if old_value != new_value:
diff[field] = {"from": old_value, "to": new_value}

await write_audit(
db,
actor_kind="user",
actor_id=current_user.id,
action="project.update",
resource_type="project",
resource_id=updated.id,
details=diff,
)
return updated
12 changes: 12 additions & 0 deletions backend/app/crud/external_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.external_results import ExternalRunSession, RunStatus
from app.models.project import Project
from app.schemas.external_results import SessionCreate, SessionFinish

# Terminal statuses — no further transitions allowed once reached.
Expand All @@ -31,6 +32,17 @@ async def create_session(
of creating a duplicate.

"""
project_result = await db.execute(select(Project.id).where(Project.id == payload.project_id))
project_id = project_result.scalar_one_or_none()
if project_id is None:
raise ValueError(
{
"code": "session.project_not_found",
"message": f"Project {payload.project_id} does not exist.",
"details": None,
}
)

cutoff = datetime.now(tz=timezone.utc).replace(tzinfo=None) - timedelta(seconds=_IDEMPOTENCY_WINDOW_SECONDS)

# Normalise ci_url to a plain string so we can compare it.
Expand Down
42 changes: 42 additions & 0 deletions backend/app/crud/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""CRUD operations for Projects."""

from uuid import UUID

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.project import Project
from app.schemas.project import ProjectCreate, ProjectUpdate


async def create_project(db: AsyncSession, payload: ProjectCreate) -> Project:
project = Project(**payload.model_dump())
db.add(project)
await db.commit()
await db.refresh(project)
return project


async def get_project(db: AsyncSession, project_id: UUID) -> Project | None:
result = await db.execute(select(Project).where(Project.id == project_id))
return result.scalar_one_or_none()


async def list_projects(db: AsyncSession, skip: int = 0, limit: int = 100) -> tuple[list[Project], int]:
count_result = await db.execute(select(func.count()).select_from(Project))
total = count_result.scalar_one()
result = await db.execute(select(Project).offset(skip).limit(limit))
return list(result.scalars().all()), total


async def update_project(db: AsyncSession, project_id: UUID, payload: ProjectUpdate) -> Project | None:
project = await get_project(db, project_id)
if project is None:
return None

for field, value in payload.model_dump(exclude_unset=True).items():
setattr(project, field, value)

await db.commit()
await db.refresh(project)
return project
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
external_results,
links,
notifications,
projects,
requirements,
suggestions,
test_cases,
Expand Down Expand Up @@ -44,6 +45,7 @@
app.include_router(users.router, prefix=settings.API_V1_PREFIX, tags=["users"])
app.include_router(notifications.router, prefix=settings.API_V1_PREFIX, tags=["notifications"])
app.include_router(external_results.router, prefix=settings.API_V1_PREFIX, tags=["external_results"])
app.include_router(projects.router, prefix=settings.API_V1_PREFIX, tags=["projects"])

# Dev-only static route: serve local artifact files when BGSTM_STORAGE_BACKEND=local.
# This is intentionally NOT mounted in production (S3 or other remote backends).
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .external_case_result import ExternalCaseResult
from .link import LinkSource, LinkType, RequirementTestCaseLink
from .notification import Notification, NotificationType
from .project import Project
from .requirement import PriorityLevel, Requirement, RequirementStatus, RequirementType
from .runner_token import RunnerToken
from .suggestion import LinkSuggestion, SuggestionMethod, SuggestionStatus
Expand All @@ -23,6 +24,7 @@
"ExternalCaseResult",
"Notification",
"NotificationType",
"Project",
"Requirement",
"RequirementType",
"PriorityLevel",
Expand Down
17 changes: 17 additions & 0 deletions backend/app/models/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import uuid

from sqlalchemy import Column, String, Text

from .base import Base, TimestampMixin
from .requirement import GUID


class Project(Base, TimestampMixin):
__tablename__ = "projects"

id = Column(GUID(), primary_key=True, default=uuid.uuid4)
name = Column(String(255), nullable=False, index=True)
description = Column(Text, nullable=True)

def __repr__(self):
return f"<Project(id={self.id}, name={self.name!r})>"
26 changes: 26 additions & 0 deletions backend/app/schemas/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from datetime import datetime
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field


class ProjectBase(BaseModel):
name: str = Field(..., max_length=255)
description: str | None = None


class ProjectCreate(ProjectBase):
pass


class ProjectUpdate(BaseModel):
name: str | None = Field(None, max_length=255)
description: str | None = None


class ProjectResponse(ProjectBase):
id: UUID
created_at: datetime
updated_at: datetime

model_config = ConfigDict(from_attributes=True)
8 changes: 7 additions & 1 deletion backend/tests/api/test_external_results_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
from app.db.session import get_db
from app.main import app
from app.models.base import Base
from app.models.project import Project
from app.models.user import User, UserRole

_PROJECT_ID = str(uuid.uuid4())


def _make_user(role: UserRole = UserRole.admin) -> User:
return User(
Expand All @@ -37,7 +40,7 @@ def _auth_header(plaintext: str) -> dict[str, str]:
def _session_payload() -> dict[str, str | dict[str, str]]:
return {
"runner": "pytest-bgstm@1.0.0",
"project_id": str(uuid.uuid4()),
"project_id": _PROJECT_ID,
"git_sha": "abc123",
"git_branch": "main",
"ci_url": f"https://ci.example.com/runs/{uuid.uuid4()}",
Expand All @@ -53,6 +56,9 @@ async def db_session():

factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
project = Project(id=uuid.UUID(_PROJECT_ID), name=f"project-{uuid.uuid4().hex[:6]}")
session.add(project)
await session.commit()

async def _override_get_db():
yield session
Expand Down
Loading
Loading