init: documentation and prototypes

This commit is contained in:
2025-12-01 22:01:19 +01:00
parent e936fb41fa
commit 45d60fc1a9
51 changed files with 2476 additions and 1 deletions

View File

@@ -0,0 +1,8 @@
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/dvd_db
REDIS_URL=redis://localhost:6379
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=dvd-blobs
SECRET_KEY=your-super-secret-key-here
OAUTH_JWKS_URL=https://id.bund.de/.well-known/jwks.json

View File

@@ -0,0 +1,16 @@
# DirektVermittlungDe Backend
## Setup
1. Clone or create folder, add files above.
2. `pip install -r requirements.txt`
3. Copy `.env.example` to `.env` and edit.
4. Run Postgres, Redis, MinIO (use Docker Compose if needed).
5. `uvicorn main:app --reload`
6. Docs: http://localhost:8000/docs
7. Tests: `pytest`
## Run Worker
arq main.worker --watch # For background jobs
## Docker (Optional)
Use docker-compose.yml for services.

View File

@@ -0,0 +1,31 @@
from typing import Dict, Any, List
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2AuthorizationCodeBearer
from jose import jwt, JWTError
from .database import async_session # For dependency injection
oauth2_scheme = OAuth2AuthorizationCodeBearer(
authorizationUrl="https://id.bund.de/auth",
tokenUrl="https://api.direktvermittlung.de/oauth/token",
scopes={"citizen:write": "Submit documents", "official:read": "View cases", "official:write": "Respond"}
)
SECRET_KEY = "your-secret" # From env
ALGORITHM = "HS256"
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
scopes: List[str] = payload.get("scope", "").split()
role = "CITIZEN" if "citizen" in scopes else "OFFICIAL"
if user_id is None:
raise credentials_exception
return {"user_id": user_id, "role": role, "scopes": scopes}
except JWTError:
raise credentials_exception

View File

@@ -0,0 +1,11 @@
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from .models import Base
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/dvd_db" # From env
engine = create_async_engine(DATABASE_URL, echo=True)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@@ -0,0 +1,132 @@
import asyncio
from concurrent.futures import ProcessPoolExecutor
from fastapi import APIRouter, BackgroundTasks, Depends, FastAPI, File, HTTPException, Query, UploadFile, status
from fastapi.security.utils import get_authorization_scheme_param
from .models import (
DocumentEnvelope, ThreadType, InteractionThread, Message, ExportRequest, ExportJob,
Document, Thread, Message as DBMessage, Metadata
)
from .database import async_session, init_db
from .auth import get_current_user
from .services import route_document, rate_limit, validate_encrypted_payload, get_s3_client, redis_settings
from .workers import export_worker
# Router
router = APIRouter()
@router.post("/documents", response_model=DocumentEnvelope, status_code=201)
async def upload_document(
background_tasks: BackgroundTasks,
metadata: Metadata,
file: UploadFile = File(...),
current_user=Depends(get_current_user),
db: AsyncSession = Depends(async_session),
redis=Depends(redis_settings.connection)
):
if current_user["role"] != "CITIZEN":
raise HTTPException(403, "Only citizens can submit")
await rate_limit(current_user["user_id"], redis)
content = await file.read()
encrypted_payload = base64.b64encode(content).decode('utf-8')
loop = asyncio.get_running_loop()
is_valid = await loop.run_in_executor(ProcessPoolExecutor(), validate_encrypted_payload, encrypted_payload)
if not is_valid:
raise HTTPException(400, "Invalid encrypted payload")
assigned_unit = route_document(metadata)
s3 = await get_s3_client()
storage_path = f"blobs/{uuid.uuid4()}.enc"
await s3.put_object(Bucket="dvd-blobs", Key=storage_path, Body=content)
doc = Document(
reference_number=metadata.reference_number,
authority_id=metadata.authority_id,
doc_type=metadata.doc_type,
status="ROUTED" if assigned_unit != "Fallback-Team" else "RECEIVED",
storage_path=storage_path,
issued_at=metadata.issued_at,
retention_date=datetime.now() + timedelta(days=30)
)
db.add(doc)
await db.commit()
await db.refresh(doc)
background_tasks.add_task(lambda: print(f"Notify {assigned_unit} about {doc.id}"))
return DocumentEnvelope(
id=doc.id,
metadata=metadata,
encrypted_payload=encrypted_payload,
status=doc.status,
retention_date=doc.retention_date
)
@router.post("/documents/{doc_id}/threads", response_model=InteractionThread, status_code=201)
async def start_thread(
doc_id: str,
thread_data: ThreadType,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(async_session)
):
if current_user["role"] != "CITIZEN":
raise HTTPException(403)
doc = await db.get(Document, doc_id)
if not doc:
raise HTTPException(404, "Document not found")
thread = Thread(document_id=doc_id, type=thread_data.type, status="PENDING_OFFICIAL")
db.add(thread)
await db.commit()
await db.refresh(thread)
if thread_data.initial_message:
msg = DBMessage(thread_id=thread.id, sender_role="CITIZEN", content=base64.b64encode(thread_data.initial_message.encode()).decode())
db.add(msg)
await db.commit()
return InteractionThread(id=thread.id, document_id=doc_id, type=thread.type, status=thread.status)
@router.get("/threads/{thread_id}/messages", response_model=List[Message])
async def get_messages(
thread_id: str,
limit: int = Query(default=20, ge=1, le=100),
before=None,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(async_session)
):
query = db.query(DBMessage).filter(DBMessage.thread_id == thread_id).order_by(DBMessage.timestamp.desc())
if before:
query = query.filter(DBMessage.timestamp < before)
messages = await query.limit(limit).all()
return [Message.model_validate(m) for m in reversed(messages)]
@router.post("/exports", response_model=ExportJob, status_code=202)
async def request_export(
export_data: ExportRequest,
current_user=Depends(get_current_user),
arq=Depends(redis_settings.worker)
):
if "official" not in current_user["scopes"]:
raise HTTPException(403)
job_id = str(uuid.uuid4())
await arq.enqueue_job("export_worker", export_data.dict(), job_id=job_id)
return ExportJob(job_id=job_id)
# App
app = FastAPI(title="DirektVermittlungDe API", version="1.0.0")
app.include_router(router)
@app.on_event("startup")
async def startup():
await init_db()
@app.get("/")
async def root():
return {"message": "DirektVermittlungDe API"}

View File

@@ -0,0 +1,90 @@
import uuid
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from pydantic import BaseModel, Field, validator
from sqlalchemy import String, Text, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy.sql import delete
class Base(DeclarativeBase):
pass
class Metadata(BaseModel):
authority_id: str = Field(..., description="Authority ID for routing")
reference_number: str = Field(..., description="Aktenzeichen/Kassenzeichen")
doc_type: str = Field(..., enum=["NOTICE", "COURT_ORDER", "GENERAL_INQUIRY"])
issued_at: datetime
@validator('doc_type')
def validate_doc_type(cls, v):
if v not in ["NOTICE", "COURT_ORDER", "GENERAL_INQUIRY"]:
raise ValueError("Invalid document type")
return v
class DocumentEnvelope(BaseModel):
id: Optional[str] = Field(default_factory=lambda: str(uuid.uuid4()), read_only=True)
metadata: Metadata
encrypted_payload: str = Field(..., description="Base64 E2E Encrypted PDF")
status: str = Field(default="RECEIVED", enum=["RECEIVED", "ROUTED", "ASSIGNED", "CLOSED"], read_only=True)
retention_date: Optional[datetime] = None
class ThreadType(BaseModel):
type: str = Field(..., enum=["TEXT_CHAT", "CALLBACK_REQUEST", "APPOINTMENT"])
initial_message: Optional[str] = None
class InteractionThread(BaseModel):
id: Optional[str] = Field(default_factory=lambda: str(uuid.uuid4()), read_only=True)
document_id: str
type: str = Field(..., enum=["TEXT_CHAT", "CALLBACK_REQUEST", "APPOINTMENT"])
status: str = Field(default="OPEN", enum=["OPEN", "PENDING_OFFICIAL", "PENDING_CITIZEN", "RESOLVED"])
assigned_official_id: Optional[str] = None
class Message(BaseModel):
id: Optional[str] = Field(default_factory=lambda: str(uuid.uuid4()), read_only=True)
thread_id: str
sender_role: str = Field(..., enum=["CITIZEN", "OFFICIAL"])
content: str = Field(..., description="Encrypted message content")
timestamp: datetime = Field(default_factory=datetime.now)
class ExportRequest(BaseModel):
case_id: str
target_system: str = "eAkte-Standard-V2"
include_attachments: bool = True
class ExportJob(BaseModel):
job_id: str
status: str = "QUEUED"
# DB Models
class Document(Base):
__tablename__ = "documents"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
reference_number: Mapped[str] = mapped_column(String(50), index=True)
authority_id: Mapped[str] = mapped_column(String(50), index=True)
doc_type: Mapped[str] = mapped_column(String(20))
status: Mapped[str] = mapped_column(String(20), default="RECEIVED")
storage_path: Mapped[str] = mapped_column(String(255)) # S3 key
issued_at: Mapped[datetime]
retention_date: Mapped[Optional[datetime]]
threads: relationship("Thread", back_populates="document")
class Thread(Base):
__tablename__ = "threads"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
document_id: Mapped[str] = mapped_column(String(36), index=True)
type: Mapped[str] = mapped_column(String(20))
status: Mapped[str] = mapped_column(String(20), default="OPEN")
assigned_official_id: Mapped[Optional[str]] = mapped_column(String(100))
document: relationship("Document", back_populates="threads")
messages: relationship("Message", back_populates="thread")
class Message(Base):
__tablename__ = "messages"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
thread_id: Mapped[str] = mapped_column(String(36), index=True)
sender_role: Mapped[str] = mapped_column(String(20))
content: Mapped[str] = mapped_column(Text) # Encrypted
timestamp: Mapped[datetime] = mapped_column(default=func.now())
thread: relationship("Thread", back_populates="messages")

View File

@@ -0,0 +1,13 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy[asyncio]==2.0.23
asyncpg==0.29.0
aioboto3==12.4.0
arq==0.18.4
redis==5.0.1
pydantic[email]==2.5.0
python-jose[cryptography]==3.3.0
python-multipart==0.0.6
pytest==7.4.3
pytest-asyncio==0.21.1
httpx==0.25.2

View File

@@ -0,0 +1,60 @@
import asyncio
import base64
import uuid
from concurrent.futures import ProcessPoolExecutor
from datetime import datetime, timedelta
from typing import Dict
import aioboto3
from arq.connections import RedisSettings
from fastapi import HTTPException, status, Query, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from .models import Metadata, Document, Thread, Message
# Global executor for CPU tasks
cpu_pool = ProcessPoolExecutor(max_workers=4)
# Redis
redis_settings = RedisSettings.from_dsn("redis://localhost")
# S3
async def get_s3_client():
return aioboto3.Session().client(
's3',
endpoint_url='http://localhost:9000', # From env
aws_access_key_id='minioadmin',
aws_secret_access_key='minioadmin'
)
# Routing Engine
def route_document(metadata: Metadata) -> str:
rules = {
"Finanzamt-München-I": "Steuerfestsetzung-Team-B",
"Gericht-Berlin": "Zivilabteilung"
}
return rules.get(metadata.authority_id, "Fallback-Team")
# Rate Limiting
async def rate_limit(user_id: str, redis, limit: int = 10, window: int = 60):
key = f"rate_limit:{user_id}"
current = await redis.get(key)
if current is None:
await redis.setex(key, window, 1)
return True
count = int(current)
if count >= limit:
raise HTTPException(status_code=429, detail="Rate limit exceeded")
await redis.incr(key)
return True
# Validate Encrypted Payload (CPU-bound)
def validate_encrypted_payload(payload_b64: str) -> bool:
payload_bytes = base64.b64decode(payload_b64)
return payload_bytes.startswith(b'\x00\x01') # Demo check
# GDPR Reaper
async def reaper(db: AsyncSession):
stmt = delete(Document).where(Document.retention_date < datetime.now())
await db.execute(stmt)
await db.commit()

View File

@@ -0,0 +1,12 @@
import pytest
from httpx import AsyncClient
from main import app # Adjust import
@pytest.mark.asyncio
async def test_upload_document():
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.post("/documents", json={
"metadata": {"authority_id": "test", "reference_number": "123", "doc_type": "NOTICE", "issued_at": "2025-12-01T00:00:00Z"},
"encrypted_payload": "dummy_base64"
})
assert response.status_code == 201

View File

@@ -0,0 +1,17 @@
import asyncio
from arq import cron
from arq.connections import RedisSettings
redis_settings = RedisSettings.from_dsn("redis://localhost")
async def export_worker(ctx, data: Dict):
# Simulate export
print(f"Exporting {data['case_id']}")
await asyncio.sleep(5)
return {"status": "COMPLETED"}
# Cron: Run reaper daily
@cron(redis_settings, '0 0 * * *') # Midnight daily
async def daily_reaper(ctx):
# Call reaper logic here
pass