Finish MVP and dockerize

This commit is contained in:
2026-04-02 19:15:47 +02:00
parent 90ad5e0260
commit 30cb18b55e
50 changed files with 2346 additions and 17 deletions

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from functools import lru_cache
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.db.session import AsyncSessionLocal
from dlib.auth import AuthenticatedUser, build_validator
security_scheme = HTTPBearer(auto_error=False)
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session
@lru_cache(maxsize=1)
def _validator():
settings = get_settings()
return build_validator(
issuer=settings.auth_oidc_issuer,
audience=settings.auth_oidc_audience,
disable=settings.auth_disable_verification,
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security_scheme),
) -> AuthenticatedUser:
validator = _validator()
token = credentials.credentials if credentials else ""
try:
return await validator.validate(token)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)
) from exc

View File

@@ -0,0 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter
from app.api.routes import documents, versions, submissions, public
api_router = APIRouter()
api_router.include_router(documents.router)
api_router.include_router(versions.router)
api_router.include_router(submissions.router)
api_router.include_router(public.router)

View File

@@ -0,0 +1,3 @@
from . import documents, versions, submissions, public
__all__ = ["documents", "versions", "submissions", "public"]

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.schemas import DocumentListResponse, DocumentResponse
from app.services.documents import create_document, get_document, list_documents
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/documents", tags=["documents"])
@router.get("/", response_model=DocumentListResponse)
async def list_user_documents(
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
documents = await list_documents(session, owner_id=user.sub)
payload = [DocumentResponse.model_validate(doc) for doc in documents]
return DocumentListResponse(items=payload)
@router.get("/{document_id}", response_model=DocumentResponse)
async def get_user_document(
document_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
document = await get_document(session, owner_id=user.sub, document_id=document_id)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentResponse.model_validate(document)
@router.post("/", response_model=DocumentResponse)
async def upload_document(
title: str = Form(...),
description: str | None = Form(default=None),
file: UploadFile = File(...),
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
document = await create_document(
session,
owner_id=user.sub,
title=title,
description=description,
upload=file,
)
return DocumentResponse.model_validate(document)

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.models import PublicAsset
from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest
from app.services.publication import publish_version
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/public", tags=["public"])
@router.post("/publish", response_model=PublicAssetResponse)
async def publish(
payload: PublishRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
asset = await publish_version(
session,
owner_id=user.sub,
version_id=payload.version_id,
submission_id=payload.submission_id,
slug=payload.slug,
)
if not asset:
raise HTTPException(status_code=404, detail="Version or submission not found")
return _response_from_asset(asset)
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)):
stmt = select(PublicAsset).where(
PublicAsset.slug == slug, PublicAsset.is_public.is_(True)
)
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
return PublicAssetLookupResponse(asset=_response_from_asset(asset))
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
settings = get_settings()
url = f"{settings.public_base_url.rstrip('/')}/cv/{asset.slug}"
return PublicAssetResponse(
id=asset.id,
slug=asset.slug,
artifact_key=asset.artifact_key,
is_public=asset.is_public,
created_at=asset.created_at,
version_id=asset.version_id,
submission_id=asset.submission_id,
url=url,
)

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.schemas import (
AiSuggestionRequest,
SubmissionCreateRequest,
SubmissionResponse,
SuggestionResponse,
)
from app.services.submissions import create_submission, request_ai_suggestions
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/submissions", tags=["submissions"])
@router.post("/", response_model=SubmissionResponse)
async def create_submission_endpoint(
payload: SubmissionCreateRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
submission = await create_submission(
session,
owner_id=user.sub,
version_id=payload.version_id,
company_name=payload.company_name,
role_title=payload.role_title,
job_url=payload.job_url,
job_description=payload.job_description,
)
if not submission:
raise HTTPException(status_code=404, detail="Version not found")
return SubmissionResponse.model_validate(submission)
@router.post("/{submission_id}/ai", response_model=list[SuggestionResponse])
async def request_submissions_ai(
submission_id: str,
payload: AiSuggestionRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
suggestions = await request_ai_suggestions(
session,
owner_id=user.sub,
submission_id=submission_id,
job_description=payload.job_description,
focus_keywords=payload.focus_keywords,
)
if suggestions is None:
raise HTTPException(status_code=404, detail="Submission not found")
return [SuggestionResponse.model_validate(item) for item in suggestions]

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.schemas import BranchCreateRequest, VersionResponse
from app.services.versions import create_branch
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/versions", tags=["versions"])
@router.post("/branches", response_model=VersionResponse)
async def create_version_branch(
payload: BranchCreateRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
version = await create_branch(
session,
owner_id=user.sub,
parent_version_id=payload.parent_version_id,
branch_name=payload.branch_name,
version_label=payload.version_label,
patches=payload.patches,
)
if not version:
raise HTTPException(status_code=404, detail="Parent version not found")
return VersionResponse.model_validate(version)