generated from coulomb/repo-seed
init: documentation and prototypes
This commit is contained in:
@@ -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
|
||||
16
prototype-grok4.1/direktvermittlungde-backend/README.txt
Normal file
16
prototype-grok4.1/direktvermittlungde-backend/README.txt
Normal 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.
|
||||
31
prototype-grok4.1/direktvermittlungde-backend/auth.py
Normal file
31
prototype-grok4.1/direktvermittlungde-backend/auth.py
Normal 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
|
||||
11
prototype-grok4.1/direktvermittlungde-backend/database.py
Normal file
11
prototype-grok4.1/direktvermittlungde-backend/database.py
Normal 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)
|
||||
132
prototype-grok4.1/direktvermittlungde-backend/main.py
Normal file
132
prototype-grok4.1/direktvermittlungde-backend/main.py
Normal 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"}
|
||||
90
prototype-grok4.1/direktvermittlungde-backend/models.py
Normal file
90
prototype-grok4.1/direktvermittlungde-backend/models.py
Normal 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")
|
||||
@@ -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
|
||||
60
prototype-grok4.1/direktvermittlungde-backend/services.py
Normal file
60
prototype-grok4.1/direktvermittlungde-backend/services.py
Normal 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()
|
||||
@@ -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
|
||||
17
prototype-grok4.1/direktvermittlungde-backend/workers.py
Normal file
17
prototype-grok4.1/direktvermittlungde-backend/workers.py
Normal 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
|
||||
Reference in New Issue
Block a user