Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1c6f520
FEAT: Add DeepSeek LLM provider integration
PanwalaVandan Mar 14, 2026
93af7d3
FIX: Resolve TypeError in risk scoring when comparing datetimes
PanwalaVandan Mar 14, 2026
8375bb7
FIX: Normalize backend snake_case findings to camelCase in Analysis page
PanwalaVandan Mar 14, 2026
4c97f8c
FIX: Associate uploaded documents with vendor when uploading from ven…
PanwalaVandan Mar 14, 2026
5f5bc68
FIX: Pre-select document in Analysis when clicking View All Findings
PanwalaVandan Mar 14, 2026
6e02ea6
FIX: Prevent infinite login/dashboard redirect loop on stale auth state
PanwalaVandan Mar 14, 2026
ad953b1
CHORE: Update frontend package-lock.json
PanwalaVandan Mar 14, 2026
cc1c6d4
FEAT: Custom Framework Builder (v1.2 Feature 1)
PanwalaVandan Mar 21, 2026
c583455
FEAT: Replace hardcoded clone starters with full built-in framework i…
PanwalaVandan Mar 25, 2026
eccfe54
FIX: Use correct 'data' key from /frameworks API response in clone modal
PanwalaVandan Mar 25, 2026
7f30f0a
FEAT: Integrate docling for structure-aware document parsing and chun…
PanwalaVandan Mar 25, 2026
cd5ab89
FIX: remove @apply bg-background and text-foreground from body rule
PanwalaVandan Mar 25, 2026
d7fd2c2
FIX: replace corrupted em-dash character in error message
PanwalaVandan Mar 25, 2026
3479409
FEAT: add password complexity validation on registration form
PanwalaVandan Mar 25, 2026
eee127e
FEAT: Deploy RAGFlow DeepDoc PDF parser as Modal serverless GPU service
PanwalaVandan Apr 4, 2026
aec9c95
FIX: resolve Modal GPU OOM and timeout in DeepDoc PDF parser
PanwalaVandan Apr 5, 2026
ed6cb05
FEAT: add soc2_tsc, iso_27001, nist_800_53, hipaa framework JSON files
PanwalaVandan Apr 5, 2026
eaf60f1
FEAT: configurable document parser providers per organization
PanwalaVandan Apr 5, 2026
de9f4d5
FEAT: add Settings page and fix /settings route bug
PanwalaVandan Apr 5, 2026
8ad6919
DOCS: add external parser contract specification
PanwalaVandan Apr 5, 2026
2e3dfd4
FIX: framework gap analysis — update all compliance frameworks to cur…
PanwalaVandan Apr 10, 2026
7206435
CHORE: remove parser-service from repo — custom infrastructure, not p…
PanwalaVandan Apr 11, 2026
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
8 changes: 7 additions & 1 deletion backend/app/api/v1/endpoints/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,19 @@ async def analyze_document(
The document must be processed before analysis.
Analysis uses Claude to identify gaps against the specified framework.
"""
if not analysis_request.framework and not analysis_request.custom_framework_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Provide either 'framework' (built-in) or 'custom_framework_id'.",
)
try:
analysis_run = await analysis_service.run_analysis(
db=db,
document_id=document_id,
org_id=current_user.organization_id,
framework=analysis_request.framework,
framework=analysis_request.framework or "custom",
chunk_limit=analysis_request.chunk_limit,
custom_framework_id=analysis_request.custom_framework_id,
)
await db.commit()
await db.refresh(analysis_run)
Expand Down
269 changes: 269 additions & 0 deletions backend/app/api/v1/endpoints/custom_frameworks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
"""Custom compliance framework CRUD endpoints.

Provides endpoints for:
- POST /custom-frameworks - create a framework
- GET /custom-frameworks - list frameworks for the org
- GET /custom-frameworks/{id} - get a framework with all controls
- PATCH /custom-frameworks/{id} - update framework metadata
- DELETE /custom-frameworks/{id} - delete a framework
- POST /custom-frameworks/{id}/controls - add a control
- PATCH /custom-frameworks/{id}/controls/{control_id} - update a control
- DELETE /custom-frameworks/{id}/controls/{control_id} - remove a control
"""

from typing import Annotated

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

from app.api.deps import get_current_active_user
from app.db import get_db
from app.models import User
from app.models.custom_framework import CustomControl, CustomFramework
from app.schemas.custom_framework import (
CustomControlCreate,
CustomControlResponse,
CustomControlUpdate,
CustomFrameworkCreate,
CustomFrameworkListResponse,
CustomFrameworkResponse,
CustomFrameworkSummary,
CustomFrameworkUpdate,
)

router = APIRouter(tags=["Custom Frameworks"])


async def _get_framework_or_404(
framework_id: str,
org_id: str,
db: AsyncSession,
load_controls: bool = False,
) -> CustomFramework:
"""Fetch a framework by ID scoped to the org, or raise 404."""
query = select(CustomFramework).where(
CustomFramework.id == framework_id,
CustomFramework.organization_id == org_id,
)
if load_controls:
query = query.options(selectinload(CustomFramework.controls))
result = await db.execute(query)
fw = result.scalar_one_or_none()
if not fw:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Framework not found")
return fw


# ── Framework CRUD ───────────────────────────────────────────────────────────

@router.post("", response_model=CustomFrameworkResponse, status_code=status.HTTP_201_CREATED)
async def create_framework(
payload: CustomFrameworkCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> CustomFrameworkResponse:
"""Create a new custom compliance framework."""
fw = CustomFramework(
organization_id=current_user.organization_id,
created_by=current_user.id,
name=payload.name,
version=payload.version,
description=payload.description,
)
db.add(fw)
await db.commit()
await db.refresh(fw)
# Reload with controls relationship (empty at creation)
result = await db.execute(
select(CustomFramework)
.where(CustomFramework.id == fw.id)
.options(selectinload(CustomFramework.controls))
)
return CustomFrameworkResponse.model_validate(result.scalar_one())


@router.get("", response_model=CustomFrameworkListResponse)
async def list_frameworks(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100),
) -> CustomFrameworkListResponse:
"""List all custom frameworks for the current organisation."""
skip = (page - 1) * limit

total_result = await db.execute(
select(func.count(CustomFramework.id)).where(
CustomFramework.organization_id == current_user.organization_id,
CustomFramework.is_active == True, # noqa: E712
)
)
total = total_result.scalar() or 0

result = await db.execute(
select(CustomFramework)
.where(
CustomFramework.organization_id == current_user.organization_id,
CustomFramework.is_active == True, # noqa: E712
)
.options(selectinload(CustomFramework.controls))
.order_by(CustomFramework.created_at.desc())
.offset(skip)
.limit(limit)
)
frameworks = result.scalars().all()

summaries = [
CustomFrameworkSummary(
id=fw.id,
name=fw.name,
version=fw.version,
description=fw.description,
is_active=fw.is_active,
control_count=len(fw.controls),
created_at=fw.created_at,
updated_at=fw.updated_at,
)
for fw in frameworks
]

return CustomFrameworkListResponse(data=summaries, total=total, page=page, limit=limit)


@router.get("/{framework_id}", response_model=CustomFrameworkResponse)
async def get_framework(
framework_id: str,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> CustomFrameworkResponse:
"""Get a framework with all its controls."""
fw = await _get_framework_or_404(
framework_id, current_user.organization_id, db, load_controls=True
)
return CustomFrameworkResponse.model_validate(fw)


@router.patch("/{framework_id}", response_model=CustomFrameworkResponse)
async def update_framework(
framework_id: str,
payload: CustomFrameworkUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> CustomFrameworkResponse:
"""Update framework metadata (name, version, description, is_active)."""
fw = await _get_framework_or_404(
framework_id, current_user.organization_id, db, load_controls=True
)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(fw, field, value)
await db.commit()
await db.refresh(fw)
result = await db.execute(
select(CustomFramework)
.where(CustomFramework.id == fw.id)
.options(selectinload(CustomFramework.controls))
)
return CustomFrameworkResponse.model_validate(result.scalar_one())


@router.delete("/{framework_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_framework(
framework_id: str,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
"""Delete a custom framework and all its controls (cascade)."""
fw = await _get_framework_or_404(framework_id, current_user.organization_id, db)
await db.delete(fw)
await db.commit()


# ── Control CRUD ─────────────────────────────────────────────────────────────

@router.post(
"/{framework_id}/controls",
response_model=CustomControlResponse,
status_code=status.HTTP_201_CREATED,
)
async def add_control(
framework_id: str,
payload: CustomControlCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> CustomControlResponse:
"""Add a control to a framework."""
await _get_framework_or_404(framework_id, current_user.organization_id, db)

control = CustomControl(
framework_id=framework_id,
control_id=payload.control_id,
name=payload.name,
description=payload.description,
category=payload.category,
guidance=payload.guidance,
order_index=payload.order_index,
)
db.add(control)
await db.commit()
await db.refresh(control)
return CustomControlResponse.model_validate(control)


@router.patch(
"/{framework_id}/controls/{control_id}",
response_model=CustomControlResponse,
)
async def update_control(
framework_id: str,
control_id: str,
payload: CustomControlUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> CustomControlResponse:
"""Update a control."""
await _get_framework_or_404(framework_id, current_user.organization_id, db)

result = await db.execute(
select(CustomControl).where(
CustomControl.id == control_id,
CustomControl.framework_id == framework_id,
)
)
control = result.scalar_one_or_none()
if not control:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Control not found")

for field, value in payload.model_dump(exclude_unset=True).items():
setattr(control, field, value)
await db.commit()
await db.refresh(control)
return CustomControlResponse.model_validate(control)


@router.delete(
"/{framework_id}/controls/{control_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_control(
framework_id: str,
control_id: str,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
"""Remove a control from a framework."""
await _get_framework_or_404(framework_id, current_user.organization_id, db)

result = await db.execute(
select(CustomControl).where(
CustomControl.id == control_id,
CustomControl.framework_id == framework_id,
)
)
control = result.scalar_one_or_none()
if not control:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Control not found")

await db.delete(control)
await db.commit()
2 changes: 2 additions & 0 deletions backend/app/api/v1/endpoints/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx
"application/msword", # .doc
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # .xlsx
"application/vnd.ms-excel", # .xls
}

# Maximum file size (50 MB)
Expand Down
Loading