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
1 change: 1 addition & 0 deletions backend-api/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from app.models.azure_connection import AzureConnection # noqa
from app.models.gcp_connection import GCPConnection # noqa
from app.models.aws_connection import AWSConnection # noqa
from app.models.user_settings import UserSettings # noqa
from app.core.config import get_settings

# this is the Alembic Config object, which provides
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""add contact us tables

Revision ID: j1k2l3m4n567
Revises: h1i2j3k4l567
Revises: j2k3l4m5n678
Create Date: 2026-01-20

"""
Expand All @@ -14,7 +14,7 @@

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Add user_settings table.

Revision ID: j2k3l4m5n678
Revises: h1i2j3k4l567
Create Date: 2026-01-18
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op


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


def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"user_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column(
"confirm_delete_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
sa.Column(
"created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False
),
sa.Column(
"updated_at",
sa.DateTime(),
server_default=sa.text("now()"),
onupdate=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", name="uq_user_settings_user_id"),
)

op.create_index(
op.f("ix_user_settings_user_id"),
"user_settings",
["user_id"],
unique=True,
)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f("ix_user_settings_user_id"), table_name="user_settings")
op.drop_table("user_settings")

Binary file added backend-api/app/Contact-ER-Diagram.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file removed backend-api/app/api/_init_.py
Empty file.
26 changes: 19 additions & 7 deletions backend-api/app/api/v1/contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,26 +136,38 @@ def track_change(field: str, old: str | None, new: str | None) -> None:

if "status" in payload.model_fields_set:
if payload.status is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="status cannot be null")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="status cannot be null",
)
track_change("status", submission.status, payload.status)
new_status = payload.status.lower()
submission.status = payload.status
if new_status == "resolved":
# If status is set to resolved and resolved_at wasn't explicitly provided,
# set it automatically.
if new_status == "resolved" and "resolved_at" not in payload.model_fields_set:
submission.resolved_at = datetime.utcnow()
elif submission.resolved_at is not None:
# If status changes away from resolved, clear resolved_at unless caller
# is explicitly setting it.
elif (
new_status != "resolved"
and submission.resolved_at is not None
and "resolved_at" not in payload.model_fields_set
):
submission.resolved_at = None

if "priority" in payload.model_fields_set:
if payload.priority is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="priority cannot be null")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="priority cannot be null",
)
track_change("priority", submission.priority, payload.priority)
submission.priority = payload.priority

if "assigned_to" in payload.model_fields_set:
if payload.assigned_to is not None:
user_result = await db.execute(
select(User).where(User.id == payload.assigned_to)
)
user_result = await db.execute(select(User).where(User.id == payload.assigned_to))
assigned_user = user_result.scalar_one_or_none()
if not assigned_user:
raise HTTPException(
Expand Down
15 changes: 14 additions & 1 deletion backend-api/app/api/v1/router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
from fastapi import APIRouter
from app.api.v1 import auth, test, evidence, m365_connections, scans, benchmarks, platforms, contact
from app.api.v1 import (
auth,
benchmarks,
contact,
evidence,
m365_connections,
platforms,
scans,
settings,
test,
)

api_router = APIRouter()

Expand All @@ -26,3 +36,6 @@

# Contact routes
api_router.include_router(contact.router)

# User settings routes
api_router.include_router(settings.router)
25 changes: 24 additions & 1 deletion backend-api/app/api/v1/scans.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Scan API endpoints."""

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

Expand Down Expand Up @@ -184,3 +184,26 @@ async def get_scan_results(

results = await db.execute(query)
return list(results.scalars().all())


@router.delete("/{scan_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_scan(
scan_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_async_session),
) -> None:
"""Delete a scan (hard delete) and its results."""
result = await db.execute(
select(Scan).where(Scan.id == scan_id, Scan.user_id == current_user.id)
)
scan = result.scalar_one_or_none()
if not scan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Scan {scan_id} not found",
)

# Delete dependent results first (FK is not ON DELETE CASCADE).
await db.execute(delete(ScanResult).where(ScanResult.scan_id == scan_id))
await db.delete(scan)
await db.commit()
56 changes: 56 additions & 0 deletions backend-api/app/api/v1/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""User settings API endpoints."""

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

from app.core.auth import get_current_user
from app.db.session import get_async_session
from app.models.user import User
from app.models.user_settings import UserSettings
from app.schemas.user_settings import UserSettingsRead, UserSettingsUpdate

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


async def _get_or_create_settings(
db: AsyncSession,
user_id: int,
) -> UserSettings:
result = await db.execute(select(UserSettings).where(UserSettings.user_id == user_id))
settings = result.scalar_one_or_none()
if settings:
return settings

settings = UserSettings(user_id=user_id)
db.add(settings)
await db.commit()
await db.refresh(settings)
return settings


@router.get("/", response_model=UserSettingsRead)
async def get_my_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_async_session),
) -> UserSettings:
"""Get settings for the current user (creates defaults on first access)."""
return await _get_or_create_settings(db, current_user.id)


@router.patch("/", response_model=UserSettingsRead)
async def update_my_settings(
update: UserSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_async_session),
) -> UserSettings:
"""Update settings for the current user."""
settings = await _get_or_create_settings(db, current_user.id)

if update.confirm_delete_enabled is not None:
settings.confirm_delete_enabled = update.confirm_delete_enabled

await db.commit()
await db.refresh(settings)
return settings

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions backend-api/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from app.models.compliance import Scan
from app.models.evidence_validation import EvidenceValidation
from app.models.contact import ContactSubmission, SubmissionNote, SubmissionHistory
from app.models.user_settings import UserSettings

__all__ = [
"User",
Expand All @@ -27,4 +28,5 @@
"ContactSubmission",
"SubmissionNote",
"SubmissionHistory",
"UserSettings",
]
7 changes: 7 additions & 0 deletions backend-api/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from app.models.m365_connection import M365Connection
from app.models.contact import ContactSubmission, SubmissionHistory, SubmissionNote
from app.models.oauth_account import OAuthAccount
from app.models.user_settings import UserSettings


class Role(str, Enum):
Expand Down Expand Up @@ -49,6 +50,12 @@ class User(SQLAlchemyBaseUserTable[int], Base):
cascade="all, delete-orphan",
lazy="selectin",
)
settings: Mapped["UserSettings"] = relationship(
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
lazy="selectin",
)
m365_connections: Mapped[list["M365Connection"]] = relationship(
back_populates="user"
)
Expand Down
42 changes: 42 additions & 0 deletions backend-api/app/models/user_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from datetime import datetime

from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func

from app.db.base import Base


class UserSettings(Base):
"""Per-user application settings.

One row per user (enforced via unique constraint on user_id).
"""

__tablename__ = "user_settings"

id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
)

# UI preference: show confirmation dialog before destructive deletes.
confirm_delete_enabled: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
server_default=text("true"),
)

created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)

user = relationship("User", back_populates="settings")

25 changes: 25 additions & 0 deletions backend-api/app/schemas/user_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Pydantic schemas for per-user settings."""

from datetime import datetime

from pydantic import BaseModel


class UserSettingsRead(BaseModel):
"""Schema for reading user settings."""

id: int
user_id: int
confirm_delete_enabled: bool
created_at: datetime
updated_at: datetime

class Config:
from_attributes = True


class UserSettingsUpdate(BaseModel):
"""Schema for updating user settings."""

confirm_delete_enabled: bool | None = None

1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ services:

powershell-service:
profiles: ["powershell", "all"]
platform: linux/amd64
build:
context: ./engine/powershell
dockerfile: Dockerfile
Expand Down
Loading
Loading