feat: Complete redesign with OIDC auth, PDF upload, and enhanced workflow

BREAKING CHANGE: Major architecture overhaul removing LaTeX compilation

- Removed embedded LaTeX compilation
- Added OIDC/OAuth2 authentication with Nextcloud integration
- Added email authentication with magic links
- Implemented role-based access control (RBAC)
- Added PDF template upload and field mapping
- Implemented visual form designer capability
- Created multi-stage approval workflow
- Added voting mechanism for AStA members
- Enhanced user dashboard with application tracking
- Added comprehensive audit trail and history
- Improved security with JWT tokens and encryption

New Features:
- OIDC single sign-on with automatic role mapping
- Dual authentication (OIDC + Email)
- Upload fillable PDFs as templates
- Graphical field mapping interface
- Configurable workflow with reviews and voting
- Admin panel for role and permission management
- Email notifications for status updates
- Docker compose setup with Redis and MailHog

Migration Required:
- Database schema updates via Alembic
- Configuration of OIDC provider
- Upload of PDF templates to replace LaTeX
- Role mapping configuration
This commit is contained in:
Frederik Beimgraben 2025-09-17 00:42:57 +02:00
parent d9c7356a65
commit ad697e5f54
28 changed files with 9278 additions and 296 deletions

View File

@ -1,69 +1,128 @@
# STUPA PDF API Configuration Example
# Copy this file to .env and update with your values
# ========================================
# STUPA PDF API Configuration
# ========================================
# Application Settings
APP_NAME="STUPA PDF API"
APP_VERSION="3.0.0"
ENVIRONMENT="development"
DEBUG=true
LOG_LEVEL="INFO"
TIMEZONE="Europe/Berlin"
FRONTEND_URL="http://localhost:3001"
# API Settings
API_PREFIX="/api"
DOCS_URL="/docs"
REDOC_URL="/redoc"
OPENAPI_URL="/openapi.json"
# Feature Flags
ENABLE_METRICS=false
ENABLE_TRACING=false
ENABLE_CACHE=true
CACHE_TTL=300
ENABLE_FORM_DESIGNER=true
ENABLE_PDF_UPLOAD=true
ENABLE_WORKFLOW=true
# ========================================
# Database Configuration
# ========================================
MYSQL_HOST=db
MYSQL_PORT=3306
MYSQL_DB=stupa
MYSQL_USER=stupa
MYSQL_PASSWORD=your_secure_password_here
MYSQL_ROOT_PASSWORD=your_secure_root_password_here
MYSQL_PASSWORD=secret
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20
DB_POOL_PRE_PING=true
DB_ECHO=false
# Authentication
# Master key for admin access - keep this secure!
MASTER_KEY=your_secure_master_key_here
# ========================================
# Security Settings
# ========================================
MASTER_KEY="change_me_to_secure_key"
JWT_SECRET_KEY="change_me_to_secure_jwt_secret"
JWT_ALGORITHM="HS256"
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
ENCRYPTION_KEY="change_me_to_32_byte_encryption_key"
API_KEY_HEADER="X-API-Key"
# CORS Settings
CORS_ORIGINS="http://localhost:3001,http://localhost:3000"
CORS_CREDENTIALS=true
CORS_METHODS="*"
CORS_HEADERS="*"
# ========================================
# OIDC/OAuth2 Settings (Nextcloud)
# ========================================
OIDC_ENABLED=true
OIDC_ISSUER="https://nextcloud.example.com"
OIDC_CLIENT_ID="your_client_id"
OIDC_CLIENT_SECRET="your_client_secret"
OIDC_REDIRECT_URI="http://localhost:3001/auth/callback"
OIDC_SCOPE="openid profile email groups"
OIDC_AUTO_CREATE_USERS=true
# OIDC Group Mappings (comma-separated)
OIDC_ADMIN_GROUPS="admin,administrators"
OIDC_BUDGET_REVIEWER_GROUPS="haushaltsbeauftragte,budget_reviewers"
OIDC_FINANCE_REVIEWER_GROUPS="finanzreferent,finance_reviewers"
OIDC_ASTA_GROUPS="asta,asta_members"
# ========================================
# Email Settings
# ========================================
EMAIL_ENABLED=true
SMTP_HOST="localhost"
SMTP_PORT=587
SMTP_TLS=true
SMTP_SSL=false
SMTP_USERNAME=""
SMTP_PASSWORD=""
EMAIL_FROM="noreply@example.com"
EMAIL_FROM_NAME="STUPA System"
# Email Templates
EMAIL_VERIFICATION_SUBJECT="Verifizieren Sie Ihre E-Mail-Adresse"
EMAIL_MAGIC_LINK_SUBJECT="Anmelden bei STUPA"
EMAIL_APP_NOTIFICATION_SUBJECT="Status-Update zu Ihrer Bewerbung"
# ========================================
# Rate Limiting
# Requests per minute per IP address
# ========================================
RATE_LIMIT_ENABLED=true
RATE_IP_PER_MIN=60
# Requests per minute per application key
RATE_KEY_PER_MIN=30
RATE_GLOBAL_PER_MIN=1000
RATE_BURST_SIZE=10
# Application Settings
# Timezone for the application
TZ=Europe/Berlin
# ========================================
# Storage Settings
# ========================================
UPLOAD_DIR="/app/uploads"
TEMPLATE_DIR="/app/templates"
MAX_FILE_SIZE=10485760 # 10MB
ALLOWED_EXTENSIONS="pdf,json,jpg,jpeg,png"
TEMP_DIR="/tmp"
ATTACHMENT_STORAGE="filesystem" # database or filesystem
FILESYSTEM_PATH="/app/attachments"
# PDF Templates (paths inside container - don't change unless you know what you're doing)
QSM_TEMPLATE=/app/assets/qsm.pdf
VSM_TEMPLATE=/app/assets/vsm.pdf
# ========================================
# Workflow Settings
# ========================================
WORKFLOW_REQUIRED_VOTES=5
WORKFLOW_APPROVAL_THRESHOLD=50.0 # Percentage
WORKFLOW_REVIEW_TIMEOUT_DAYS=14
WORKFLOW_VOTING_TIMEOUT_DAYS=7
WORKFLOW_ALLOW_MANUAL_STATUS_CHANGE=true
WORKFLOW_AUTO_LOCK_ON_SUBMISSION=true
# Frontend Configuration
NODE_ENV=production
# Optional: CORS Configuration (for production)
# CORS_ORIGINS=["https://your-domain.com"]
# Optional: Debug Mode (never enable in production)
# DEBUG=false
# Optional: Database Connection Pool
# DB_POOL_SIZE=10
# DB_MAX_OVERFLOW=20
# Optional: File Upload Limits
# MAX_UPLOAD_SIZE=104857600 # 100MB in bytes
# MAX_ATTACHMENTS_PER_APP=30
# Optional: Session Configuration
# SESSION_TIMEOUT=3600 # 1 hour in seconds
# Optional: Email Configuration (for future notifications)
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=notifications@example.com
# SMTP_PASSWORD=smtp_password_here
# SMTP_FROM=noreply@example.com
# Optional: Logging Configuration
# LOG_LEVEL=INFO
# LOG_FILE=/var/log/stupa-api.log
# Optional: Backup Configuration
# BACKUP_ENABLED=true
# BACKUP_SCHEDULE="0 2 * * *" # Daily at 2 AM
# BACKUP_RETENTION_DAYS=30
# Production Security Headers (uncomment for production)
# SECURE_HEADERS=true
# HSTS_ENABLED=true
# CSP_ENABLED=true
# ========================================
# Docker Compose Specific
# ========================================
MYSQL_ROOT_PASSWORD=rootsecret
TZ="Europe/Berlin"

310
README_NEW_ARCHITECTURE.md Normal file
View File

@ -0,0 +1,310 @@
# STUPA PDF API - New Architecture (v3.0)
## Overview
The STUPA PDF API has been completely redesigned to support modern authentication, flexible PDF form handling, and a sophisticated workflow system. This document describes the new architecture and features.
## Key Changes from Previous Version
### 1. **Removed LaTeX Compilation**
- No more embedded LaTeX compilation
- Users upload fillable PDF templates instead
- More flexible and maintainable approach
### 2. **OIDC/OAuth2 Authentication**
- Full integration with Nextcloud via OIDC
- Automatic role mapping from OIDC groups
- Single Sign-On (SSO) support
### 3. **Dual Authentication System**
- **OIDC**: Primary authentication via Nextcloud
- **Email**: Magic link authentication as fallback
- Seamless switching between auth methods
### 4. **Role-Based Access Control (RBAC)**
- Dynamic role assignment from OIDC claims
- Configurable permissions per role
- Special roles for workflow participants
### 5. **PDF Template Management**
- Upload any fillable PDF as a template
- Graphical field mapping interface
- Support for multiple form types
### 6. **Visual Form Designer**
- Drag-and-drop form builder
- Custom styling and themes
- Responsive form layouts
### 7. **Advanced Workflow System**
- Multi-stage approval process
- Voting mechanism for AStA members
- Automatic status transitions
## Architecture Components
### Backend Services
#### Authentication Services
- `auth_oidc.py`: OIDC/OAuth2 authentication with Nextcloud
- `auth_email.py`: Email-based authentication with magic links
#### PDF Services
- `pdf_template.py`: Template upload and field mapping
- `pdf_processor.py`: PDF filling and generation
#### Workflow Services
- `workflow_engine.py`: Application workflow management
- `voting_service.py`: Voting and approval handling
### Database Schema
#### Core Tables
- `users`: User accounts with OIDC integration
- `roles`: Role definitions with permissions
- `sessions`: Active user sessions
#### Form Management
- `form_templates`: PDF template definitions
- `field_mappings`: PDF field to data mappings
- `form_designs`: Visual form configurations
#### Application Management
- `applications`: Main application records
- `application_votes`: Voting records
- `application_history`: Audit trail
- `application_attachments`: File attachments
## Workflow Process
The new application workflow follows this state machine:
```
DRAFT → BEANTRAGT → BEARBEITUNG_GESPERRT → ZU_PRUEFEN → ZUR_ABSTIMMUNG → GENEHMIGT/ABGELEHNT
```
### States Explained
1. **DRAFT**: Initial creation, user can edit
2. **BEANTRAGT**: Submitted by user, awaiting processing
3. **BEARBEITUNG_GESPERRT**: Processing locked, under review
4. **ZU_PRUEFEN**: To be reviewed by Haushaltsbeauftragte & Finanzreferent
5. **ZUR_ABSTIMMUNG**: Open for voting by AStA members (requires 5 votes)
6. **GENEHMIGT**: Approved
7. **ABGELEHNT**: Rejected (can be manually reset to BEANTRAGT)
## Configuration
### Environment Variables
#### OIDC Configuration
```env
OIDC_ENABLED=true
OIDC_ISSUER=https://nextcloud.example.com
OIDC_CLIENT_ID=your_client_id
OIDC_CLIENT_SECRET=your_client_secret
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
```
#### Role Mapping from OIDC Groups
```env
OIDC_ADMIN_GROUPS=admin,administrators
OIDC_BUDGET_REVIEWER_GROUPS=haushaltsbeauftragte
OIDC_FINANCE_REVIEWER_GROUPS=finanzreferent
OIDC_ASTA_GROUPS=asta,asta_members
```
#### Email Configuration
```env
EMAIL_ENABLED=true
SMTP_HOST=localhost
SMTP_PORT=587
EMAIL_FROM=noreply@example.com
```
#### Workflow Configuration
```env
WORKFLOW_REQUIRED_VOTES=5
WORKFLOW_APPROVAL_THRESHOLD=50.0
WORKFLOW_REVIEW_TIMEOUT_DAYS=14
WORKFLOW_VOTING_TIMEOUT_DAYS=7
```
## API Endpoints
### Authentication
- `POST /api/auth/oidc/authorize` - Initiate OIDC flow
- `POST /api/auth/oidc/callback` - OIDC callback handler
- `POST /api/auth/email/register` - Email registration
- `POST /api/auth/email/verify` - Verify email token
- `POST /api/auth/email/login` - Request magic link
- `GET /api/auth/me` - Get current user
### Form Templates
- `GET /api/templates` - List available templates
- `POST /api/templates` - Upload new template (admin)
- `GET /api/templates/{id}` - Get template details
- `PUT /api/templates/{id}/mappings` - Update field mappings
- `POST /api/templates/{id}/test` - Test field mappings
### Applications
- `GET /api/applications` - List user's applications
- `POST /api/applications` - Create new application
- `GET /api/applications/{id}` - Get application details
- `PUT /api/applications/{id}` - Update application
- `POST /api/applications/{id}/submit` - Submit for review
- `POST /api/applications/{id}/review` - Review application
- `POST /api/applications/{id}/vote` - Vote on application
### Admin
- `GET /api/admin/users` - List all users
- `PUT /api/admin/users/{id}/roles` - Update user roles
- `GET /api/admin/roles` - List all roles
- `POST /api/admin/roles/{id}/mappings` - Configure OIDC mappings
## Frontend Components
### User Dashboard
- View all applications
- Track application status
- Access application history
### Form Designer (Admin)
- Visual form builder interface
- Drag-drop field arrangement
- Field property configuration
- Conditional logic builder
### PDF Field Mapper (Admin)
- Upload PDF templates
- Auto-detect form fields
- Map fields to data model
- Configure validation rules
### Voting Interface (AStA Members)
- Review pending applications
- Cast votes with comments
- View voting history
## Security Features
### Authentication
- JWT-based session management
- Refresh token rotation
- Automatic session expiry
### Authorization
- Role-based permissions
- Resource-level access control
- API rate limiting
### Data Protection
- Encrypted token storage
- Secure password hashing
- CSRF protection
- XSS prevention
## Deployment
### Docker Compose
```bash
# Development
docker-compose up -d
# Production
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
### Required Services
- MySQL 8.0+
- Redis 7.0+
- SMTP server (for email auth)
- Nextcloud instance (for OIDC)
### Volumes
- `/app/uploads` - Uploaded files
- `/app/templates` - PDF templates
- `/app/attachments` - Application attachments
## Migration from v2
1. **Backup existing data**
```bash
docker-compose exec db mysqldump -u root -p stupa > backup.sql
```
2. **Run migrations**
```bash
docker-compose exec api python -m alembic upgrade head
```
3. **Configure OIDC**
- Register application in Nextcloud
- Configure redirect URIs
- Map groups to roles
4. **Upload PDF templates**
- Convert existing LaTeX templates to fillable PDFs
- Upload via admin interface
- Configure field mappings
## Development
### Setup Development Environment
```bash
# Clone repository
git clone https://github.com/your-org/stupa-pdf-api.git
cd stupa-pdf-api
# Checkout new architecture branch
git checkout feature/oidc-pdf-upload-redesign
# Copy environment file
cp .env.example .env
# Start services
docker-compose --profile dev up
```
### Testing
```bash
# Run backend tests
docker-compose exec api pytest
# Run frontend tests
docker-compose exec frontend npm test
# Test OIDC integration
docker-compose exec api python -m tests.test_oidc
```
## Troubleshooting
### OIDC Connection Issues
- Verify Nextcloud OAuth2 app is configured
- Check redirect URI matches configuration
- Ensure client ID/secret are correct
### PDF Processing Issues
- Verify PDF has fillable form fields
- Check field names don't contain special characters
- Ensure PDF is not password protected
### Email Delivery Issues
- Check SMTP configuration
- Verify firewall allows SMTP port
- Test with local mail server (MailHog)
## Support
For issues or questions about the new architecture:
1. Check the documentation
2. Review the migration guide
3. Contact the development team
## License
This project is licensed under the MIT License - see the LICENSE file for details.

420
backend/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,420 @@
# Backend Architecture Documentation
## Overview
The backend has been refactored from a monolithic structure into a modular, service-oriented architecture that emphasizes:
- **Separation of Concerns**: Clear boundaries between layers (API, Service, Repository, Model)
- **Dependency Injection**: Dynamic service resolution and configuration
- **Extensibility**: Plugin-based system for PDF variants and providers
- **Maintainability**: Organized code structure with single responsibility principle
- **Scalability**: Stateless services with proper connection pooling
## Directory Structure
```
backend/
├── src/
│ ├── api/ # API Layer
│ │ ├── routes/ # FastAPI routers
│ │ ├── middleware/ # Custom middleware
│ │ └── dependencies/ # Dependency injection helpers
│ │
│ ├── services/ # Business Logic Layer
│ │ ├── base.py # Base service classes
│ │ ├── application.py # Application business logic
│ │ ├── pdf.py # PDF processing service
│ │ └── auth.py # Authentication service
│ │
│ ├── repositories/ # Data Access Layer
│ │ ├── base.py # Base repository pattern
│ │ ├── application.py # Application repository
│ │ └── attachment.py # Attachment repository
│ │
│ ├── models/ # Database Models
│ │ ├── base.py # Base model with mixins
│ │ └── application.py # Application entities
│ │
│ ├── providers/ # Dynamic Providers
│ │ ├── pdf_qsm.py # QSM PDF variant provider
│ │ └── pdf_vsm.py # VSM PDF variant provider
│ │
│ ├── config/ # Configuration Management
│ │ └── settings.py # Centralized settings with Pydantic
│ │
│ ├── core/ # Core Infrastructure
│ │ ├── container.py # Dependency injection container
│ │ └── database.py # Database management
│ │
│ └── utils/ # Utility Functions
│ └── helpers.py # Common utilities
```
## Architecture Layers
### 1. API Layer (`api/`)
**Responsibility**: HTTP request/response handling, validation, routing
- **Routes**: Modular FastAPI routers for different domains
- **Middleware**: Cross-cutting concerns (rate limiting, logging, error handling)
- **Dependencies**: FastAPI dependency injection functions
```python
# Example: api/routes/applications.py
@router.post("/", response_model=ApplicationResponse)
async def create_application(
data: ApplicationCreate,
service: ApplicationService = Depends(get_application_service)
):
return await service.create(data.dict())
```
### 2. Service Layer (`services/`)
**Responsibility**: Business logic, orchestration, validation rules
- Encapsulates all business rules and workflows
- Coordinates between repositories and external services
- Handles complex validations and transformations
- Stateless and testable
```python
# Example: services/application.py
class ApplicationService(CRUDService[Application]):
def submit_application(self, id: int) -> Application:
# Business logic for submission
app = self.repository.get_or_404(id)
self._validate_submission(app)
app.status = ApplicationStatus.SUBMITTED
return self.repository.update(app)
```
### 3. Repository Layer (`repositories/`)
**Responsibility**: Data access abstraction, CRUD operations
- Implements repository pattern for database access
- Provides clean abstraction over SQLAlchemy
- Handles query building and optimization
- Transaction management
```python
# Example: repositories/application.py
class ApplicationRepository(BaseRepository[Application]):
def find_by_status(self, status: ApplicationStatus) -> List[Application]:
return self.query().filter(
Application.status == status
).all()
```
### 4. Model Layer (`models/`)
**Responsibility**: Data structure definition, ORM mapping
- SQLAlchemy models with proper relationships
- Base classes with common functionality (timestamps, soft delete)
- Model mixins for reusable behavior
- Business entity representation
```python
# Example: models/application.py
class Application(ExtendedBaseModel):
__tablename__ = "applications"
pa_id = Column(String(64), unique=True, index=True)
status = Column(SQLEnum(ApplicationStatus))
payload = Column(JSON)
```
## Key Components
### Dependency Injection Container
The system uses a custom dependency injection container for managing service lifecycles:
```python
# core/container.py
class Container:
def register_service(self, name: str, service_class: Type[BaseService]):
# Register service with automatic dependency resolution
def get_service(self, name: str) -> BaseService:
# Retrieve service instance with dependencies injected
```
**Benefits:**
- Loose coupling between components
- Easy testing with mock services
- Dynamic service configuration
- Singleton pattern support
### Configuration Management
Centralized configuration using Pydantic Settings:
```python
# config/settings.py
class Settings(BaseSettings):
database: DatabaseSettings
security: SecuritySettings
rate_limit: RateLimitSettings
storage: StorageSettings
pdf: PDFSettings
app: ApplicationSettings
```
**Features:**
- Environment variable support
- Type validation
- Default values
- Configuration file support (JSON/YAML)
- Dynamic override capability
### Provider Pattern for PDF Variants
Extensible system for handling different PDF types:
```python
# providers/pdf_qsm.py
class QSMProvider(PDFVariantProvider):
def parse_pdf_fields(self, fields: Dict) -> Dict:
# QSM-specific parsing logic
def map_payload_to_fields(self, payload: Dict) -> Dict:
# QSM-specific field mapping
```
**Advantages:**
- Easy to add new PDF variants
- Variant-specific validation rules
- Dynamic provider registration
- Clean separation of variant logic
## Database Architecture
### Base Model Classes
```python
# models/base.py
class BaseModel:
# Common fields and methods
class TimestampMixin:
created_at = Column(DateTime)
updated_at = Column(DateTime)
class SoftDeleteMixin:
is_deleted = Column(Boolean)
deleted_at = Column(DateTime)
class AuditMixin:
created_by = Column(String)
updated_by = Column(String)
```
### Connection Management
- Connection pooling with configurable size
- Automatic retry on connection failure
- Session scoping for transaction management
- Health check utilities
## Service Patterns
### CRUD Service Base
```python
class CRUDService(BaseService):
def create(self, data: Dict) -> T
def update(self, id: Any, data: Dict) -> T
def delete(self, id: Any, soft: bool = True) -> bool
def get(self, id: Any) -> Optional[T]
def list(self, filters: Dict, page: int, page_size: int) -> Dict
```
### Error Handling
Hierarchical exception system:
```python
ServiceException
├── ValidationError
├── BusinessRuleViolation
├── ResourceNotFoundError
└── ResourceConflictError
```
### Transaction Management
```python
with service.handle_errors("operation"):
with repository.transaction():
# Perform multiple operations
# Automatic rollback on error
```
## API Design
### RESTful Endpoints
```
POST /api/applications # Create application
GET /api/applications # List applications
GET /api/applications/{id} # Get application
PUT /api/applications/{id} # Update application
DELETE /api/applications/{id} # Delete application
POST /api/applications/{id}/submit # Submit application
POST /api/applications/{id}/review # Review application
GET /api/applications/{id}/pdf # Generate PDF
```
### Request/Response Models
Using Pydantic for validation:
```python
class ApplicationCreate(BaseModel):
variant: ApplicationType
payload: Dict[str, Any]
class ApplicationResponse(BaseModel):
id: int
pa_id: str
status: ApplicationStatus
created_at: datetime
```
## Middleware Stack
1. **CORS Middleware**: Cross-origin resource sharing
2. **Rate Limit Middleware**: Request throttling
3. **Logging Middleware**: Request/response logging
4. **Error Handler Middleware**: Global error handling
5. **Authentication Middleware**: JWT/API key validation
## Security Features
- JWT-based authentication
- API key support
- Rate limiting per IP/key
- SQL injection prevention via ORM
- Input sanitization
- Audit logging
## Performance Optimizations
- Database connection pooling
- Lazy loading relationships
- Query optimization with indexes
- Caching support (Redis)
- Async request handling
- PDF generation caching
## Testing Strategy
### Unit Tests
- Service logic testing
- Repository method testing
- Model validation testing
### Integration Tests
- API endpoint testing
- Database transaction testing
- PDF processing testing
### End-to-End Tests
- Complete workflow testing
- Multi-service interaction testing
## Deployment Considerations
### Environment Variables
```env
# Database
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_DB=stupa
MYSQL_USER=user
MYSQL_PASSWORD=password
# Security
MASTER_KEY=secret_key
JWT_SECRET_KEY=jwt_secret
# Rate Limiting
RATE_IP_PER_MIN=60
RATE_KEY_PER_MIN=30
# PDF Templates
QSM_TEMPLATE=assets/qsm.pdf
VSM_TEMPLATE=assets/vsm.pdf
```
### Docker Support
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
### Scaling Considerations
- Stateless services for horizontal scaling
- Database read replicas support
- Cache layer for frequently accessed data
- Async processing for heavy operations
- Message queue integration ready
## Migration Path
### From Old to New Architecture
1. **Phase 1**: Setup new structure alongside old code
2. **Phase 2**: Migrate database models
3. **Phase 3**: Implement service layer
4. **Phase 4**: Create API routes
5. **Phase 5**: Migrate business logic
6. **Phase 6**: Remove old code
### Database Migrations
Using Alembic for version control:
```bash
alembic init migrations
alembic revision --autogenerate -m "Initial migration"
alembic upgrade head
```
## Monitoring & Observability
- Structured logging with context
- Prometheus metrics integration
- Health check endpoints
- Performance profiling hooks
- Error tracking integration ready
## Future Enhancements
1. **GraphQL Support**: Alternative API interface
2. **WebSocket Support**: Real-time updates
3. **Event Sourcing**: Audit trail and history
4. **Microservices**: Service decomposition
5. **API Gateway**: Advanced routing and auth
6. **Message Queue**: Async task processing
7. **Search Engine**: Elasticsearch integration
8. **Machine Learning**: PDF field prediction
## Conclusion
This refactored architecture provides:
- **Maintainability**: Clear structure and separation
- **Scalability**: Ready for growth
- **Testability**: Isolated components
- **Extensibility**: Plugin-based design
- **Performance**: Optimized patterns
- **Security**: Built-in best practices
The modular design allows teams to work independently on different components while maintaining system integrity through well-defined interfaces.

View File

@ -1,71 +1,61 @@
# ---------- LaTeX Builder Stage ----------
FROM texlive/texlive:latest AS latex-builder
# Install additional dependencies for LaTeX
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
inkscape \
make \
fonts-liberation \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/* \
&& inkscape --version
WORKDIR /latex
# Copy the LaTeX source files from local worktrees
COPY latex-qsm /latex/qsm
COPY latex-vsm /latex/vsm
# Build QSM PDF
WORKDIR /latex/qsm
RUN latexmk -xelatex -interaction=nonstopmode -halt-on-error -shell-escape Main.tex && \
cp Main.pdf /latex/qsm.pdf && \
latexmk -c
# Build VSM PDF
WORKDIR /latex/vsm
RUN latexmk -xelatex -interaction=nonstopmode -shell-escape Main.tex && \
cp Main.pdf /latex/vsm.pdf && \
latexmk -c
# ---------- Base Python Stage ----------
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# System deps
RUN apt-get update && apt-get install -y --no-install-recommends \
tzdata ca-certificates \
qpdf \
pdftk-java \
libmupdf-dev \
mupdf-tools \
&& rm -rf /var/lib/apt/lists/*
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# ---------- Dependencies ----------
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
libmariadb-dev \
pkg-config \
wget \
curl \
# PDF processing tools
poppler-utils \
# Clean up
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Install additional PDF processing libraries
RUN pip install --no-cache-dir \
PyMuPDF \
pypdf \
pillow \
python-multipart \
httpx \
redis \
python-jose[cryptography] \
passlib \
bcrypt \
emails \
jinja2
# ---------- App ----------
# Copy application code
COPY src/ /app/src/
COPY src/ ./src/
COPY assets/ ./assets/
# Copy pre-built PDFs from latex-builder stage
COPY --from=latex-builder /latex/qsm.pdf /app/assets/qsm.pdf
COPY --from=latex-builder /latex/vsm.pdf /app/assets/vsm.pdf
# Create necessary directories
RUN mkdir -p /app/uploads \
/app/templates \
/app/attachments \
/app/pdf_forms \
/app/logs
# Set Python path
ENV PYTHONPATH=/app/src
# Set permissions
RUN chmod -R 755 /app
# Configure PDF template paths
ENV QSM_TEMPLATE=/app/assets/qsm.pdf \
VSM_TEMPLATE=/app/assets/vsm.pdf
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# Expose port
EXPOSE 8000
# ---------- Run ----------
CMD ["uvicorn", "service_api:app", "--host", "0.0.0.0", "--port", "8000"]
# Run the application
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

@ -1 +0,0 @@
Subproject commit 4dca8b58bc350b5cd1835499ed6119b7e2220794

@ -1 +0,0 @@
Subproject commit c5aa64c41e25a4a938928c266460f0a020ab7b14

View File

@ -1,20 +1,89 @@
# Core API & HTTP
fastapi>=0.110
uvicorn[standard]>=0.27
# Core Framework
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.3
pydantic-settings==2.1.0
python-multipart==0.0.6
# Data parsing / validation
pydantic>=2.6
# Database
sqlalchemy==2.0.25
pymysql==1.1.0
alembic==1.13.1
cryptography==41.0.7
# PDF handling
PyPDF2>=3.0.1
PyMuPDF>=1.23.0
# Authentication & Security
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.1.2
authlib==1.3.0
httpx==0.26.0
requests==2.31.0
# DB (MySQL via SQLAlchemy + PyMySQL)
SQLAlchemy>=2.0
PyMySQL>=1.1
# OIDC/OAuth2
oauthlib==3.2.2
requests-oauthlib==1.3.1
# Env handling
python-dotenv>=1.0
# PDF Processing
pypdf==3.17.4
PyMuPDF==1.23.16
reportlab==4.0.8
pillow==10.2.0
pypdfium2==4.25.0
# File uploads (FastAPI Form/File)
python-multipart>=0.0.9
# Email
emails==0.6
aiosmtplib==3.0.1
email-validator==2.1.0.post1
# Caching & Sessions
redis==5.0.1
python-redis==0.6.0
# Template Processing
jinja2==3.1.3
markupsafe==2.1.4
# Utilities
python-dotenv==1.0.0
pytz==2023.3.post1
croniter==2.0.1
python-dateutil==2.8.2
# File Processing
python-magic==0.4.27
filetype==1.2.0
# API Documentation
openapi-schema-pydantic==1.2.4
# Testing (optional, for development)
pytest==7.4.4
pytest-asyncio==0.23.3
pytest-cov==4.1.0
httpx-mock==0.4.0
faker==22.0.0
# Development Tools
black==23.12.1
flake8==7.0.0
mypy==1.8.0
pre-commit==3.6.0
# Logging & Monitoring
python-json-logger==2.0.7
sentry-sdk[fastapi]==1.39.2
# Data Validation & Serialization
marshmallow==3.20.2
pyyaml==6.0.1
# Background Tasks (optional)
celery==5.3.6
kombu==5.3.5
flower==2.0.1
# Rate Limiting
slowapi==0.1.9
# CORS & Security Headers
secure==0.3.0

View File

@ -1,156 +0,0 @@
#!/usr/bin/env python3
"""
Make all form fields in LaTeX documents readonly by adding readonly=true attribute.
This script carefully parses LaTeX commands to avoid breaking the syntax.
"""
import re
import sys
from pathlib import Path
def add_readonly_to_textfield(content):
"""Add readonly=true to CustomTextFieldDefault commands."""
# Pattern to match CustomTextFieldDefault{...}{...}{...}{params}
pattern = r'(\\CustomTextFieldDefault\{[^}]*\}\{[^}]*\}\{[^}]*\}\{)([^}]*)\}'
def replacer(match):
prefix = match.group(1)
params = match.group(2)
# Check if readonly is already present
if 'readonly=' in params:
return match.group(0)
# Add readonly=true to the parameters
if params.strip():
new_params = params + ',readonly=true'
else:
new_params = 'readonly=true'
return prefix + new_params + '}'
return re.sub(pattern, replacer, content)
def add_readonly_to_choicemenu(content):
"""Add readonly=true to CustomChoiceMenuDefault commands."""
# Pattern to match CustomChoiceMenuDefault{...}{...}{params}{...}
pattern = r'(\\CustomChoiceMenuDefault\{[^}]*\}\{[^}]*\}\{)([^}]*)\}(\{[^}]*\})'
def replacer(match):
prefix = match.group(1)
params = match.group(2)
suffix = match.group(3)
# Check if readonly is already present
if 'readonly=' in params:
return match.group(0)
# Add readonly=true to the parameters
if params.strip():
new_params = params + ',readonly=true'
else:
new_params = 'readonly=true'
return prefix + new_params + '}' + suffix
return re.sub(pattern, replacer, content)
def add_readonly_to_checkbox(content):
"""Add readonly=true to CheckBox commands."""
# Pattern to match CheckBox[params]
pattern = r'(\\CheckBox\[)([^\]]*)\]'
def replacer(match):
prefix = match.group(1)
params = match.group(2)
# Check if readonly is already present
if 'readonly=' in params:
return match.group(0)
# Add readonly=true to the parameters
params_lines = params.split('\n')
# Find a good place to insert readonly=true (after first parameter)
for i, line in enumerate(params_lines):
if line.strip() and not line.strip().startswith('%'):
# Insert after this line
params_lines.insert(i + 1, '\t\t\t\treadonly=true,')
break
new_params = '\n'.join(params_lines)
return prefix + new_params + ']'
return re.sub(pattern, replacer, content, flags=re.MULTILINE | re.DOTALL)
def add_readonly_to_textfield_multiline(content):
"""Add readonly=true to TextField commands (multiline text fields)."""
# Pattern to match TextField[params] for multiline fields
pattern = r'(\\TextField\[)([^\]]*name=pa-project-description[^\]]*)\]'
def replacer(match):
prefix = match.group(1)
params = match.group(2)
# Check if readonly is already present
if 'readonly=' in params:
return match.group(0)
# Add readonly=true to the parameters
params_lines = params.split('\n')
# Find a good place to insert readonly=true (after multiline parameter)
for i, line in enumerate(params_lines):
if 'multiline' in line:
# Insert after this line
params_lines.insert(i + 1, '\t\t\t\treadonly=true,')
break
new_params = '\n'.join(params_lines)
return prefix + new_params + ']'
return re.sub(pattern, replacer, content, flags=re.MULTILINE | re.DOTALL)
def process_file(filepath):
"""Process a single LaTeX file to make all fields readonly."""
print(f"Processing {filepath}...")
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Apply transformations
content = add_readonly_to_textfield(content)
content = add_readonly_to_choicemenu(content)
content = add_readonly_to_checkbox(content)
content = add_readonly_to_textfield_multiline(content)
# Write back
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print(f"✓ Processed {filepath}")
def main():
"""Main function to process QSM and VSM LaTeX files."""
base_dir = Path(__file__).parent.parent
files_to_process = [
base_dir / "latex-qsm" / "Content" / "01_content.tex",
base_dir / "latex-vsm" / "Content" / "01_content.tex",
]
for filepath in files_to_process:
if filepath.exists():
process_file(filepath)
else:
print(f"✗ File not found: {filepath}")
if __name__ == "__main__":
main()

391
backend/src/api/v1/auth.py Normal file
View File

@ -0,0 +1,391 @@
"""
Authentication API Routes
This module provides API endpoints for OIDC and email authentication.
"""
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...services.auth_oidc import OIDCAuthService
from ...services.auth_email import EmailAuthService
from ...models.user import User
from ...config.settings import get_settings
router = APIRouter(prefix="/auth", tags=["Authentication"])
security = HTTPBearer()
settings = get_settings()
class EmailRegisterRequest(BaseModel):
"""Email registration request"""
email: EmailStr
name: Optional[str] = None
class EmailLoginRequest(BaseModel):
"""Email login request"""
email: EmailStr
class TokenVerifyRequest(BaseModel):
"""Token verification request"""
token: str
class OIDCCallbackRequest(BaseModel):
"""OIDC callback request"""
code: str
state: str
class RefreshTokenRequest(BaseModel):
"""Refresh token request"""
refresh_token: str
class UserProfileUpdate(BaseModel):
"""User profile update request"""
given_name: Optional[str] = None
family_name: Optional[str] = None
display_name: Optional[str] = None
class TokenResponse(BaseModel):
"""Token response model"""
access_token: str
refresh_token: Optional[str] = None
token_type: str = "Bearer"
expires_in: int
user: Dict[str, Any]
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""Get current authenticated user from JWT token"""
token = credentials.credentials
email_service = EmailAuthService(db, settings)
try:
user = await email_service.get_current_user(token)
return user
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)
@router.get("/oidc/authorize")
async def oidc_authorize(
request: Request,
db: Session = Depends(get_db)
) -> Dict[str, str]:
"""Initiate OIDC authorization flow"""
if not settings.oidc.enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="OIDC authentication is not enabled"
)
async with OIDCAuthService(db, settings) as service:
state = service.generate_state_token()
nonce = service.generate_nonce()
# Store state and nonce in session (or cache)
request.session["oidc_state"] = state
request.session["oidc_nonce"] = nonce
authorization_url = service.get_authorization_url(state, nonce)
return {
"authorization_url": authorization_url,
"state": state
}
@router.post("/oidc/callback", response_model=TokenResponse)
async def oidc_callback(
request: Request,
callback_data: OIDCCallbackRequest,
db: Session = Depends(get_db)
) -> TokenResponse:
"""Handle OIDC callback"""
if not settings.oidc.enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="OIDC authentication is not enabled"
)
# Retrieve stored state and nonce
stored_state = request.session.get("oidc_state")
stored_nonce = request.session.get("oidc_nonce")
if not stored_state:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid session state"
)
async with OIDCAuthService(db, settings) as service:
user, token_data = await service.authenticate_user(
code=callback_data.code,
state=callback_data.state,
stored_state=stored_state,
nonce=stored_nonce
)
# Clear session state
request.session.pop("oidc_state", None)
request.session.pop("oidc_nonce", None)
return TokenResponse(**token_data)
@router.post("/email/register", response_model=Dict[str, str])
async def email_register(
registration: EmailRegisterRequest,
db: Session = Depends(get_db)
) -> Dict[str, str]:
"""Register with email"""
if not settings.email.enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email authentication is not enabled"
)
service = EmailAuthService(db, settings)
user = await service.register_user(
email=registration.email,
name=registration.name
)
return {
"message": "Verification email sent",
"email": user.email
}
@router.post("/email/verify", response_model=TokenResponse)
async def email_verify(
verification: TokenVerifyRequest,
db: Session = Depends(get_db)
) -> TokenResponse:
"""Verify email with token"""
if not settings.email.enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email authentication is not enabled"
)
service = EmailAuthService(db, settings)
user, token_data = await service.verify_email(verification.token)
return TokenResponse(**token_data)
@router.post("/email/login", response_model=Dict[str, str])
async def email_login(
login: EmailLoginRequest,
db: Session = Depends(get_db)
) -> Dict[str, str]:
"""Request magic login link"""
if not settings.email.enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email authentication is not enabled"
)
service = EmailAuthService(db, settings)
await service.login_with_magic_link(login.email)
return {
"message": "If the email exists, a login link has been sent",
"email": login.email
}
@router.post("/email/magic-link", response_model=TokenResponse)
async def email_magic_link(
verification: TokenVerifyRequest,
db: Session = Depends(get_db)
) -> TokenResponse:
"""Login with magic link token"""
if not settings.email.enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email authentication is not enabled"
)
service = EmailAuthService(db, settings)
user, token_data = await service.verify_magic_link(verification.token)
return TokenResponse(**token_data)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
refresh_request: RefreshTokenRequest,
db: Session = Depends(get_db)
) -> TokenResponse:
"""Refresh access token"""
# Try OIDC service first
if settings.oidc.enabled:
async with OIDCAuthService(db, settings) as service:
try:
token_data = await service.refresh_access_token(refresh_request.refresh_token)
return TokenResponse(**token_data)
except HTTPException:
pass
# Try email service
if settings.email.enabled:
service = EmailAuthService(db, settings)
try:
# Email service doesn't have refresh implementation yet,
# but we can add it if needed
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
except HTTPException:
pass
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
@router.post("/logout")
async def logout(
user: User = Depends(get_current_user),
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> Dict[str, str]:
"""Logout current user"""
token = credentials.credentials
# Logout based on user's auth provider
if user.auth_provider.value == "oidc" and settings.oidc.enabled:
async with OIDCAuthService(db, settings) as service:
await service.logout(user, token)
elif user.auth_provider.value == "email" and settings.email.enabled:
# Email service can implement logout if needed
pass
return {"message": "Logged out successfully"}
@router.get("/me", response_model=Dict[str, Any])
async def get_current_user_profile(
user: User = Depends(get_current_user)
) -> Dict[str, Any]:
"""Get current user profile"""
return user.to_dict(include_sensitive=False)
@router.put("/me", response_model=Dict[str, Any])
async def update_current_user_profile(
profile_update: UserProfileUpdate,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> Dict[str, Any]:
"""Update current user profile"""
# Update based on auth provider
if user.auth_provider.value == "email" and settings.email.enabled:
service = EmailAuthService(db, settings)
updated_user = await service.update_user_profile(
user=user,
given_name=profile_update.given_name,
family_name=profile_update.family_name,
display_name=profile_update.display_name
)
return updated_user.to_dict(include_sensitive=False)
# OIDC users might have restricted profile updates
if user.auth_provider.value == "oidc":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Profile updates are managed through your identity provider"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Profile updates not available"
)
@router.post("/resend-verification", response_model=Dict[str, str])
async def resend_verification(
email_request: EmailLoginRequest,
db: Session = Depends(get_db)
) -> Dict[str, str]:
"""Resend verification email"""
if not settings.email.enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email authentication is not enabled"
)
service = EmailAuthService(db, settings)
await service.resend_verification(email_request.email)
return {
"message": "If the email exists and is unverified, a verification email has been sent",
"email": email_request.email
}
@router.get("/providers")
async def get_auth_providers() -> Dict[str, Any]:
"""Get available authentication providers"""
providers = []
if settings.oidc.enabled:
providers.append({
"type": "oidc",
"name": "Nextcloud",
"enabled": True,
"issuer": settings.oidc.issuer
})
if settings.email.enabled:
providers.append({
"type": "email",
"name": "Email",
"enabled": True,
"features": ["magic_link", "verification"]
})
return {
"providers": providers,
"default": "oidc" if settings.oidc.enabled else "email"
}

View File

@ -0,0 +1,347 @@
"""
Application Settings and Configuration Management
This module provides centralized configuration management using Pydantic Settings.
All environment variables and application settings are defined here with proper
validation and type hints.
"""
from typing import Optional, Dict, Any, List
from functools import lru_cache
from pydantic import Field, field_validator, ConfigDict
from pydantic_settings import BaseSettings
from pathlib import Path
class DatabaseSettings(BaseSettings):
"""Database connection settings"""
host: str = Field(default="127.0.0.1", env="MYSQL_HOST")
port: int = Field(default=3306, env="MYSQL_PORT")
database: str = Field(default="stupa", env="MYSQL_DB")
username: str = Field(default="stupa", env="MYSQL_USER")
password: str = Field(default="secret", env="MYSQL_PASSWORD")
pool_size: int = Field(default=10, env="DB_POOL_SIZE")
max_overflow: int = Field(default=20, env="DB_MAX_OVERFLOW")
pool_pre_ping: bool = Field(default=True, env="DB_POOL_PRE_PING")
echo: bool = Field(default=False, env="DB_ECHO")
@property
def dsn(self) -> str:
"""Generate database connection string"""
return (
f"mysql+pymysql://{self.username}:{self.password}@"
f"{self.host}:{self.port}/{self.database}?charset=utf8mb4"
)
model_config = ConfigDict(env_prefix="")
class SecuritySettings(BaseSettings):
"""Security-related settings"""
master_key: str = Field(default="", env="MASTER_KEY")
jwt_secret_key: Optional[str] = Field(default=None, env="JWT_SECRET_KEY")
jwt_algorithm: str = Field(default="HS256", env="JWT_ALGORITHM")
access_token_expire_minutes: int = Field(default=30, env="ACCESS_TOKEN_EXPIRE_MINUTES")
refresh_token_expire_days: int = Field(default=7, env="REFRESH_TOKEN_EXPIRE_DAYS")
api_key_header: str = Field(default="X-API-Key", env="API_KEY_HEADER")
cors_origins: List[str] = Field(default=["*"], env="CORS_ORIGINS")
cors_credentials: bool = Field(default=True, env="CORS_CREDENTIALS")
cors_methods: List[str] = Field(default=["*"], env="CORS_METHODS")
cors_headers: List[str] = Field(default=["*"], env="CORS_HEADERS")
encryption_key: str = Field(default="", env="ENCRYPTION_KEY")
@field_validator("cors_origins", "cors_methods", "cors_headers", mode="before")
@classmethod
def parse_cors_list(cls, v):
if isinstance(v, str):
return [item.strip() for item in v.split(",")]
return v
model_config = ConfigDict(env_prefix="")
class OIDCSettings(BaseSettings):
"""OIDC/OAuth2 settings for Nextcloud integration"""
enabled: bool = Field(default=False, env="OIDC_ENABLED")
issuer: str = Field(default="", env="OIDC_ISSUER") # e.g., https://nextcloud.example.com
client_id: str = Field(default="", env="OIDC_CLIENT_ID")
client_secret: str = Field(default="", env="OIDC_CLIENT_SECRET")
redirect_uri: str = Field(default="", env="OIDC_REDIRECT_URI")
scope: str = Field(default="openid profile email groups", env="OIDC_SCOPE")
# Role mapping from OIDC groups
admin_groups: List[str] = Field(default=[], env="OIDC_ADMIN_GROUPS")
budget_reviewer_groups: List[str] = Field(default=[], env="OIDC_BUDGET_REVIEWER_GROUPS")
finance_reviewer_groups: List[str] = Field(default=[], env="OIDC_FINANCE_REVIEWER_GROUPS")
asta_groups: List[str] = Field(default=[], env="OIDC_ASTA_GROUPS")
# Auto-create users from OIDC
auto_create_users: bool = Field(default=True, env="OIDC_AUTO_CREATE_USERS")
@field_validator("admin_groups", "budget_reviewer_groups", "finance_reviewer_groups", "asta_groups", mode="before")
@classmethod
def parse_groups(cls, v):
if isinstance(v, str):
return [item.strip() for item in v.split(",") if item.strip()]
return v
model_config = ConfigDict(env_prefix="")
class EmailSettings(BaseSettings):
"""Email configuration settings"""
enabled: bool = Field(default=False, env="EMAIL_ENABLED")
smtp_host: str = Field(default="localhost", env="SMTP_HOST")
smtp_port: int = Field(default=587, env="SMTP_PORT")
smtp_tls: bool = Field(default=True, env="SMTP_TLS")
smtp_ssl: bool = Field(default=False, env="SMTP_SSL")
smtp_username: str = Field(default="", env="SMTP_USERNAME")
smtp_password: str = Field(default="", env="SMTP_PASSWORD")
from_email: str = Field(default="noreply@example.com", env="EMAIL_FROM")
from_name: str = Field(default="STUPA System", env="EMAIL_FROM_NAME")
# Email templates
verification_subject: str = Field(default="Verify your email", env="EMAIL_VERIFICATION_SUBJECT")
magic_link_subject: str = Field(default="Login to STUPA", env="EMAIL_MAGIC_LINK_SUBJECT")
application_notification_subject: str = Field(default="Application Status Update", env="EMAIL_APP_NOTIFICATION_SUBJECT")
model_config = ConfigDict(env_prefix="")
class RateLimitSettings(BaseSettings):
"""Rate limiting settings"""
enabled: bool = Field(default=True, env="RATE_LIMIT_ENABLED")
ip_per_minute: int = Field(default=60, env="RATE_IP_PER_MIN")
key_per_minute: int = Field(default=30, env="RATE_KEY_PER_MIN")
global_per_minute: int = Field(default=1000, env="RATE_GLOBAL_PER_MIN")
burst_size: int = Field(default=10, env="RATE_BURST_SIZE")
model_config = ConfigDict(env_prefix="")
class StorageSettings(BaseSettings):
"""File storage settings"""
upload_dir: Path = Field(default=Path("/tmp/uploads"), env="UPLOAD_DIR")
template_dir: Path = Field(default=Path("/tmp/templates"), env="TEMPLATE_DIR")
max_file_size: int = Field(default=10 * 1024 * 1024, env="MAX_FILE_SIZE") # 10MB
allowed_extensions: List[str] = Field(
default=["pdf", "json", "jpg", "jpeg", "png"],
env="ALLOWED_EXTENSIONS"
)
temp_dir: Path = Field(default=Path("/tmp"), env="TEMP_DIR")
attachment_storage: str = Field(default="database", env="ATTACHMENT_STORAGE") # database or filesystem
filesystem_path: Optional[Path] = Field(default=None, env="FILESYSTEM_PATH")
@field_validator("allowed_extensions", mode="before")
@classmethod
def parse_extensions(cls, v):
if isinstance(v, str):
return [ext.strip().lower() for ext in v.split(",")]
return v
def validate_paths(self):
"""Create directories if they don't exist"""
self.upload_dir.mkdir(parents=True, exist_ok=True)
self.template_dir.mkdir(parents=True, exist_ok=True)
self.temp_dir.mkdir(parents=True, exist_ok=True)
if self.filesystem_path:
self.filesystem_path.mkdir(parents=True, exist_ok=True)
model_config = ConfigDict(env_prefix="")
class WorkflowSettings(BaseSettings):
"""Workflow configuration settings"""
# Required number of votes for approval
required_votes: int = Field(default=5, env="WORKFLOW_REQUIRED_VOTES")
# Vote thresholds (percentage)
approval_threshold: float = Field(default=50.0, env="WORKFLOW_APPROVAL_THRESHOLD")
# Timeouts (in days)
review_timeout_days: int = Field(default=14, env="WORKFLOW_REVIEW_TIMEOUT_DAYS")
voting_timeout_days: int = Field(default=7, env="WORKFLOW_VOTING_TIMEOUT_DAYS")
# Allow manual status changes
allow_manual_status_change: bool = Field(default=True, env="WORKFLOW_ALLOW_MANUAL_STATUS_CHANGE")
# Auto-lock on submission
auto_lock_on_submission: bool = Field(default=True, env="WORKFLOW_AUTO_LOCK_ON_SUBMISSION")
model_config = ConfigDict(env_prefix="")
class ApplicationSettings(BaseSettings):
"""General application settings"""
app_name: str = Field(default="STUPA PDF API", env="APP_NAME")
app_version: str = Field(default="3.0.0", env="APP_VERSION")
debug: bool = Field(default=False, env="DEBUG")
environment: str = Field(default="production", env="ENVIRONMENT")
log_level: str = Field(default="INFO", env="LOG_LEVEL")
timezone: str = Field(default="Europe/Berlin", env="TIMEZONE")
# Frontend URL for emails and redirects
frontend_url: str = Field(default="http://localhost:3001", env="FRONTEND_URL")
# API settings
api_prefix: str = Field(default="/api", env="API_PREFIX")
docs_url: Optional[str] = Field(default="/docs", env="DOCS_URL")
redoc_url: Optional[str] = Field(default="/redoc", env="REDOC_URL")
openapi_url: Optional[str] = Field(default="/openapi.json", env="OPENAPI_URL")
# Feature flags
enable_metrics: bool = Field(default=False, env="ENABLE_METRICS")
enable_tracing: bool = Field(default=False, env="ENABLE_TRACING")
enable_cache: bool = Field(default=True, env="ENABLE_CACHE")
cache_ttl: int = Field(default=300, env="CACHE_TTL") # seconds
# New feature flags
enable_form_designer: bool = Field(default=True, env="ENABLE_FORM_DESIGNER")
enable_pdf_upload: bool = Field(default=True, env="ENABLE_PDF_UPLOAD")
enable_workflow: bool = Field(default=True, env="ENABLE_WORKFLOW")
@field_validator("environment")
@classmethod
def validate_environment(cls, v):
allowed = ["development", "staging", "production", "testing"]
if v.lower() not in allowed:
raise ValueError(f"environment must be one of {allowed}")
return v.lower()
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v):
allowed = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
if v.upper() not in allowed:
raise ValueError(f"log_level must be one of {allowed}")
return v.upper()
model_config = ConfigDict(env_prefix="")
class Settings(BaseSettings):
"""Main settings class that aggregates all setting groups"""
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
security: SecuritySettings = Field(default_factory=SecuritySettings)
oidc: OIDCSettings = Field(default_factory=OIDCSettings)
email: EmailSettings = Field(default_factory=EmailSettings)
rate_limit: RateLimitSettings = Field(default_factory=RateLimitSettings)
storage: StorageSettings = Field(default_factory=StorageSettings)
workflow: WorkflowSettings = Field(default_factory=WorkflowSettings)
app: ApplicationSettings = Field(default_factory=ApplicationSettings)
# Dynamic configuration support
config_file: Optional[Path] = Field(default=None, env="CONFIG_FILE")
config_overrides: Dict[str, Any] = Field(default_factory=dict)
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Validate and create necessary directories
self.storage.validate_paths()
# Load configuration from file if specified
if self.config_file and self.config_file.exists():
self._load_config_file()
def _load_config_file(self):
"""Load configuration from JSON or YAML file"""
import json
try:
import yaml
has_yaml = True
except ImportError:
has_yaml = False
if self.config_file.suffix == ".json":
with open(self.config_file) as f:
config = json.load(f)
elif self.config_file.suffix in [".yaml", ".yml"] and has_yaml:
with open(self.config_file) as f:
config = yaml.safe_load(f)
else:
return
# Apply overrides from config file
self._apply_overrides(config)
def _apply_overrides(self, overrides: Dict[str, Any]):
"""Apply configuration overrides"""
for key, value in overrides.items():
if "." in key:
# Nested configuration
parts = key.split(".")
obj = self
for part in parts[:-1]:
obj = getattr(obj, part)
setattr(obj, parts[-1], value)
else:
if hasattr(self, key):
setattr(self, key, value)
def to_dict(self) -> Dict[str, Any]:
"""Export settings as dictionary"""
return {
"database": {
"host": self.database.host,
"port": self.database.port,
"database": self.database.database,
},
"security": {
"cors_origins": self.security.cors_origins,
"api_key_header": self.security.api_key_header,
},
"oidc": {
"enabled": self.oidc.enabled,
"issuer": self.oidc.issuer,
},
"email": {
"enabled": self.email.enabled,
"from_email": self.email.from_email,
},
"rate_limit": {
"enabled": self.rate_limit.enabled,
"ip_per_minute": self.rate_limit.ip_per_minute,
},
"storage": {
"upload_dir": str(self.storage.upload_dir),
"template_dir": str(self.storage.template_dir),
"max_file_size": self.storage.max_file_size,
},
"workflow": {
"required_votes": self.workflow.required_votes,
"approval_threshold": self.workflow.approval_threshold,
},
"app": {
"name": self.app.app_name,
"version": self.app.app_version,
"environment": self.app.environment,
"enable_form_designer": self.app.enable_form_designer,
"enable_pdf_upload": self.app.enable_pdf_upload,
}
}
model_config = ConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="allow"
)
@lru_cache()
def get_settings() -> Settings:
"""Get cached settings instance"""
return Settings()
# Convenience function for getting settings
settings = get_settings()

View File

@ -0,0 +1,401 @@
"""
Dependency Injection Container
This module provides a dependency injection container for managing
services, repositories, and other dependencies throughout the application.
"""
from typing import Dict, Any, Type, Optional, Callable, TypeVar, Generic
from functools import lru_cache
import inspect
import logging
from contextlib import contextmanager
from sqlalchemy.orm import Session, sessionmaker
from ..config.settings import Settings, get_settings
from ..repositories.base import BaseRepository
from ..services.base import BaseService
logger = logging.getLogger(__name__)
T = TypeVar('T')
class ServiceRegistry:
"""Registry for storing service instances and factories"""
def __init__(self):
self._services: Dict[str, Any] = {}
self._factories: Dict[str, Callable] = {}
self._singletons: Dict[str, Any] = {}
def register(
self,
name: str,
factory: Optional[Callable] = None,
instance: Optional[Any] = None,
singleton: bool = False
):
"""
Register a service.
Args:
name: Service name
factory: Factory function to create service instances
instance: Pre-created instance
singleton: Whether to create only one instance
"""
if instance is not None:
self._services[name] = instance
elif factory is not None:
if singleton:
self._singletons[name] = None
self._factories[name] = factory
else:
raise ValueError("Either factory or instance must be provided")
def get(self, name: str) -> Any:
"""Get a service instance"""
# Check if we have a direct instance
if name in self._services:
return self._services[name]
# Check if it's a singleton that's already created
if name in self._singletons and self._singletons[name] is not None:
return self._singletons[name]
# Check if we have a factory
if name in self._factories:
instance = self._factories[name]()
# Store singleton if needed
if name in self._singletons:
self._singletons[name] = instance
return instance
raise KeyError(f"Service '{name}' not found")
def has(self, name: str) -> bool:
"""Check if a service is registered"""
return name in self._services or name in self._factories
def clear(self):
"""Clear all registered services"""
self._services.clear()
self._factories.clear()
self._singletons.clear()
class Container:
"""
Main dependency injection container.
This container manages the lifecycle of all services, repositories,
and other dependencies in the application.
"""
def __init__(self, settings: Optional[Settings] = None):
self.settings = settings or get_settings()
self._registry = ServiceRegistry()
self._session_factory: Optional[sessionmaker] = None
self._current_session: Optional[Session] = None
self._initialize()
def _initialize(self):
"""Initialize the container with default services"""
# Register settings as a singleton
self._registry.register("settings", instance=self.settings)
# Register session factory
self._registry.register(
"session_factory",
factory=self._create_session_factory,
singleton=True
)
def _create_session_factory(self) -> sessionmaker:
"""Create SQLAlchemy session factory"""
if self._session_factory is None:
from sqlalchemy import create_engine
engine = create_engine(
self.settings.database.dsn,
pool_size=self.settings.database.pool_size,
max_overflow=self.settings.database.max_overflow,
pool_pre_ping=self.settings.database.pool_pre_ping,
echo=self.settings.database.echo
)
self._session_factory = sessionmaker(
bind=engine,
autoflush=False,
autocommit=False
)
return self._session_factory
@contextmanager
def session_scope(self):
"""
Provide a transactional scope for database operations.
Usage:
with container.session_scope() as session:
# Use session
pass
"""
session_factory = self._registry.get("session_factory")
session = session_factory()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def get_session(self) -> Session:
"""Get current database session"""
if self._current_session is None:
session_factory = self._registry.get("session_factory")
self._current_session = session_factory()
return self._current_session
def close_session(self):
"""Close current database session"""
if self._current_session:
self._current_session.close()
self._current_session = None
def register_repository(
self,
name: str,
repository_class: Type[BaseRepository],
singleton: bool = False
):
"""
Register a repository class.
Args:
name: Repository name
repository_class: Repository class
singleton: Whether to create only one instance
"""
def factory():
session = self.get_session()
return repository_class(session)
self._registry.register(name, factory=factory, singleton=singleton)
def register_service(
self,
name: str,
service_class: Type[BaseService],
dependencies: Optional[Dict[str, str]] = None,
singleton: bool = False
):
"""
Register a service class with dependency injection.
Args:
name: Service name
service_class: Service class
dependencies: Dictionary mapping parameter names to service names
singleton: Whether to create only one instance
"""
def factory():
# Get constructor signature
sig = inspect.signature(service_class.__init__)
kwargs = {}
# Resolve dependencies
for param_name, param in sig.parameters.items():
if param_name == "self":
continue
# Check if we have a mapping for this parameter
if dependencies and param_name in dependencies:
service_name = dependencies[param_name]
if self._registry.has(service_name):
kwargs[param_name] = self._registry.get(service_name)
# Try to auto-resolve by parameter name
elif self._registry.has(param_name):
kwargs[param_name] = self._registry.get(param_name)
# Check if it's the container itself
elif param.annotation == Container or param_name == "container":
kwargs[param_name] = self
# Check if it's settings
elif param.annotation == Settings or param_name == "settings":
kwargs[param_name] = self.settings
# Check if it's a session
elif param.annotation == Session or param_name == "session":
kwargs[param_name] = self.get_session()
# Skip if it has a default value
elif param.default != inspect.Parameter.empty:
continue
else:
logger.warning(
f"Could not resolve dependency '{param_name}' for service '{name}'"
)
return service_class(**kwargs)
self._registry.register(name, factory=factory, singleton=singleton)
def get(self, name: str) -> Any:
"""Get a service or repository instance"""
return self._registry.get(name)
def get_repository(self, name: str) -> BaseRepository:
"""Get a repository instance"""
repo = self._registry.get(name)
if not isinstance(repo, BaseRepository):
raise TypeError(f"'{name}' is not a repository")
return repo
def get_service(self, name: str) -> BaseService:
"""Get a service instance"""
service = self._registry.get(name)
if not isinstance(service, BaseService):
raise TypeError(f"'{name}' is not a service")
return service
def resolve(self, service_class: Type[T]) -> T:
"""
Resolve a service class to an instance.
This method attempts to find a registered service by class type.
"""
# Search for registered service by class
for name in ["settings", "session_factory"]:
try:
instance = self._registry.get(name)
if isinstance(instance, service_class):
return instance
except KeyError:
continue
# Try to create a new instance with dependency injection
return self._auto_wire(service_class)
def _auto_wire(self, service_class: Type[T]) -> T:
"""
Automatically wire dependencies for a service class.
Args:
service_class: Service class to instantiate
Returns:
Service instance with dependencies injected
"""
sig = inspect.signature(service_class.__init__)
kwargs = {}
for param_name, param in sig.parameters.items():
if param_name == "self":
continue
# Try to resolve by type annotation
if param.annotation != inspect.Parameter.empty:
if param.annotation == Container:
kwargs[param_name] = self
elif param.annotation == Settings:
kwargs[param_name] = self.settings
elif param.annotation == Session:
kwargs[param_name] = self.get_session()
elif self._registry.has(param.annotation.__name__.lower()):
kwargs[param_name] = self._registry.get(param.annotation.__name__.lower())
# Try to resolve by parameter name
if param_name not in kwargs and self._registry.has(param_name):
kwargs[param_name] = self._registry.get(param_name)
# Skip if it has a default value
if param_name not in kwargs and param.default == inspect.Parameter.empty:
raise ValueError(
f"Cannot resolve dependency '{param_name}' for {service_class.__name__}"
)
return service_class(**kwargs)
def register_provider(self, provider: 'BaseProvider'):
"""
Register all services from a provider.
Args:
provider: Provider instance
"""
provider.register(self)
def clear(self):
"""Clear all registered services and close session"""
self.close_session()
self._registry.clear()
class BaseProvider:
"""Base class for service providers"""
def register(self, container: Container):
"""
Register services with the container.
Args:
container: Container instance
"""
raise NotImplementedError
# Global container instance
_container: Optional[Container] = None
def get_container() -> Container:
"""Get the global container instance"""
global _container
if _container is None:
_container = Container()
return _container
def set_container(container: Container):
"""Set the global container instance"""
global _container
_container = container
@lru_cache()
def create_container(settings: Optional[Settings] = None) -> Container:
"""Create and configure a new container instance"""
container = Container(settings)
# Register default repositories
from ..repositories.application import ApplicationRepository
from ..repositories.attachment import AttachmentRepository
container.register_repository("application_repository", ApplicationRepository)
container.register_repository("attachment_repository", AttachmentRepository)
# Register default services
from ..services.application import ApplicationService
from ..services.pdf import PDFService
from ..services.auth import AuthService
container.register_service(
"application_service",
ApplicationService,
dependencies={
"repository": "application_repository",
"pdf_service": "pdf_service"
},
singleton=True
)
container.register_service("pdf_service", PDFService, singleton=True)
container.register_service("auth_service", AuthService, singleton=True)
return container

View File

@ -0,0 +1,402 @@
"""
Database Initialization and Management
This module provides database initialization, connection management,
and migration support for the application.
"""
from typing import Optional, Generator, Any
from contextlib import contextmanager
import logging
from sqlalchemy import create_engine, event, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker, scoped_session
from sqlalchemy.pool import QueuePool
from sqlalchemy.exc import SQLAlchemyError
from ..config.settings import Settings, get_settings
from ..models.base import Base
from ..models.application import (
Application,
ApplicationAttachment,
Attachment,
ComparisonOffer,
CostPositionJustification,
Counter
)
logger = logging.getLogger(__name__)
class DatabaseManager:
"""Manages database connections and operations"""
def __init__(self, settings: Optional[Settings] = None):
"""
Initialize database manager.
Args:
settings: Application settings
"""
self.settings = settings or get_settings()
self._engine: Optional[Engine] = None
self._session_factory: Optional[sessionmaker] = None
self._scoped_session: Optional[scoped_session] = None
@property
def engine(self) -> Engine:
"""Get or create database engine"""
if self._engine is None:
self._engine = self._create_engine()
return self._engine
@property
def session_factory(self) -> sessionmaker:
"""Get or create session factory"""
if self._session_factory is None:
self._session_factory = sessionmaker(
bind=self.engine,
autoflush=False,
autocommit=False,
expire_on_commit=False
)
return self._session_factory
@property
def scoped_session(self) -> scoped_session:
"""Get or create scoped session"""
if self._scoped_session is None:
self._scoped_session = scoped_session(self.session_factory)
return self._scoped_session
def _create_engine(self) -> Engine:
"""Create SQLAlchemy engine with configuration"""
engine = create_engine(
self.settings.database.dsn,
poolclass=QueuePool,
pool_size=self.settings.database.pool_size,
max_overflow=self.settings.database.max_overflow,
pool_pre_ping=self.settings.database.pool_pre_ping,
echo=self.settings.database.echo,
future=True,
connect_args={
"connect_timeout": 10,
"charset": "utf8mb4"
}
)
# Add event listeners
self._setup_event_listeners(engine)
return engine
def _setup_event_listeners(self, engine: Engine):
"""Setup database event listeners"""
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
"""Set SQLite pragmas for better performance"""
if "sqlite" in self.settings.database.dsn:
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.close()
@event.listens_for(engine, "checkout")
def ping_connection(dbapi_conn, connection_record, connection_proxy):
"""Ping connection to verify it's still valid"""
if self.settings.database.pool_pre_ping:
try:
dbapi_conn.ping(False)
except Exception:
# Connection is dead, raise DisconnectionError
raise SQLAlchemyError("Connection failed ping test")
def create_session(self) -> Session:
"""Create a new database session"""
return self.session_factory()
@contextmanager
def session_scope(self) -> Generator[Session, None, None]:
"""
Provide a transactional scope for database operations.
Yields:
Database session
Usage:
with db_manager.session_scope() as session:
# Perform database operations
pass
"""
session = self.create_session()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def init_database(self, drop_existing: bool = False):
"""
Initialize database tables.
Args:
drop_existing: Whether to drop existing tables first
"""
try:
if drop_existing:
logger.warning("Dropping existing database tables...")
Base.metadata.drop_all(bind=self.engine)
logger.info("Creating database tables...")
Base.metadata.create_all(bind=self.engine)
# Initialize default data
self._init_default_data()
logger.info("Database initialization complete")
except SQLAlchemyError as e:
logger.error(f"Database initialization failed: {e}")
raise
def _init_default_data(self):
"""Initialize default data in the database"""
with self.session_scope() as session:
# Initialize counters
counters = [
{
"key": "application_id",
"value": 0,
"prefix": "PA",
"format_string": "{prefix}{value:06d}"
},
{
"key": "attachment_id",
"value": 0,
"prefix": "ATT",
"format_string": "{prefix}{value:08d}"
}
]
for counter_data in counters:
existing = session.query(Counter).filter_by(
key=counter_data["key"]
).first()
if not existing:
counter = Counter(**counter_data)
session.add(counter)
logger.info(f"Created counter: {counter_data['key']}")
def verify_connection(self) -> bool:
"""
Verify database connection is working.
Returns:
True if connection is successful
"""
try:
with self.engine.connect() as conn:
result = conn.execute(text("SELECT 1"))
return result.scalar() == 1
except SQLAlchemyError as e:
logger.error(f"Database connection verification failed: {e}")
return False
def get_table_stats(self) -> Dict[str, int]:
"""
Get row counts for all tables.
Returns:
Dictionary mapping table names to row counts
"""
stats = {}
with self.session_scope() as session:
for table in Base.metadata.sorted_tables:
try:
count = session.query(table).count()
stats[table.name] = count
except SQLAlchemyError:
stats[table.name] = -1
return stats
def execute_raw_sql(self, sql: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""
Execute raw SQL query.
Args:
sql: SQL query string
params: Query parameters
Returns:
Query result
Warning:
Use with caution - prefer ORM queries when possible
"""
with self.engine.connect() as conn:
result = conn.execute(text(sql), params or {})
conn.commit()
return result
def backup_database(self, backup_path: str):
"""
Create database backup.
Args:
backup_path: Path to save backup file
Note:
Implementation depends on database type
"""
if "sqlite" in self.settings.database.dsn:
import shutil
import re
# Extract database file path from DSN
match = re.search(r'sqlite:///(.+)', self.settings.database.dsn)
if match:
db_path = match.group(1)
shutil.copy2(db_path, backup_path)
logger.info(f"Database backed up to {backup_path}")
else:
# For MySQL, would need to use mysqldump
logger.warning("Backup not implemented for this database type")
def close(self):
"""Close database connections"""
if self._scoped_session:
self._scoped_session.remove()
if self._engine:
self._engine.dispose()
self._engine = None
self._session_factory = None
self._scoped_session = None
# Global database manager instance
_db_manager: Optional[DatabaseManager] = None
def get_db_manager() -> DatabaseManager:
"""Get global database manager instance"""
global _db_manager
if _db_manager is None:
_db_manager = DatabaseManager()
return _db_manager
def init_database(settings: Optional[Settings] = None, drop_existing: bool = False):
"""
Initialize the database.
Args:
settings: Application settings
drop_existing: Whether to drop existing tables
"""
db_manager = DatabaseManager(settings)
db_manager.init_database(drop_existing)
return db_manager
def get_db_session() -> Generator[Session, None, None]:
"""
Get database session for FastAPI dependency injection.
Yields:
Database session
"""
db_manager = get_db_manager()
session = db_manager.create_session()
try:
yield session
finally:
session.close()
class DatabaseHealthCheck:
"""Database health check utilities"""
@staticmethod
def check_connection(db_manager: Optional[DatabaseManager] = None) -> Dict[str, Any]:
"""
Check database connection health.
Returns:
Health check results
"""
manager = db_manager or get_db_manager()
try:
# Test connection
is_connected = manager.verify_connection()
if is_connected:
# Get table statistics
stats = manager.get_table_stats()
total_rows = sum(v for v in stats.values() if v >= 0)
return {
"status": "healthy",
"connected": True,
"tables": len(stats),
"total_rows": total_rows,
"details": stats
}
else:
return {
"status": "unhealthy",
"connected": False,
"error": "Connection test failed"
}
except Exception as e:
return {
"status": "unhealthy",
"connected": False,
"error": str(e)
}
@staticmethod
def check_migrations() -> Dict[str, Any]:
"""
Check database migration status.
Returns:
Migration status information
"""
# This would integrate with Alembic or similar migration tool
# For now, just check if tables exist
manager = get_db_manager()
required_tables = [
"applications",
"attachments",
"application_attachments",
"comparison_offers",
"cost_position_justifications",
"counters"
]
with manager.session_scope() as session:
existing_tables = []
for table_name in required_tables:
try:
session.execute(text(f"SELECT 1 FROM {table_name} LIMIT 1"))
existing_tables.append(table_name)
except SQLAlchemyError:
pass
missing_tables = set(required_tables) - set(existing_tables)
return {
"status": "up_to_date" if not missing_tables else "pending",
"required_tables": required_tables,
"existing_tables": existing_tables,
"missing_tables": list(missing_tables)
}

207
backend/src/main.py Normal file
View File

@ -0,0 +1,207 @@
"""
Main FastAPI Application
This module creates and configures the main FastAPI application with
modular routers, middleware, and dependency injection.
"""
from contextlib import asynccontextmanager
from typing import Dict, Any
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import JSONResponse
import logging
from .config.settings import get_settings
from .core.container import create_container, get_container
from .core.database import init_database
from .api.middleware.rate_limit import RateLimitMiddleware
from .api.middleware.logging import LoggingMiddleware
from .api.middleware.error_handler import ErrorHandlerMiddleware
from .api.routes import (
application_router,
attachment_router,
pdf_router,
auth_router,
health_router
)
from .providers.pdf_qsm import QSMProvider
from .providers.pdf_vsm import VSMProvider
from .services.pdf import PDFService
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Application lifespan manager.
Handles startup and shutdown events.
"""
# Startup
logger.info("Starting up application...")
# Initialize settings
settings = get_settings()
# Initialize database
init_database(settings)
# Initialize dependency injection container
container = create_container(settings)
# Register PDF providers
pdf_service = container.get_service("pdf_service")
pdf_service.register_provider(QSMProvider(settings))
pdf_service.register_provider(VSMProvider(settings))
# Store container in app state
app.state.container = container
app.state.settings = settings
logger.info(f"Application started in {settings.app.environment} mode")
yield
# Shutdown
logger.info("Shutting down application...")
container.clear()
logger.info("Application shutdown complete")
def create_app() -> FastAPI:
"""
Create and configure the FastAPI application.
Returns:
Configured FastAPI application instance
"""
settings = get_settings()
# Create FastAPI app
app = FastAPI(
title=settings.app.app_name,
version=settings.app.app_version,
description="PDF Processing API for STUPA Applications",
docs_url=settings.app.docs_url,
redoc_url=settings.app.redoc_url,
openapi_url=settings.app.openapi_url,
lifespan=lifespan
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.security.cors_origins,
allow_credentials=settings.security.cors_credentials,
allow_methods=settings.security.cors_methods,
allow_headers=settings.security.cors_headers,
)
# Add trusted host middleware
if settings.app.environment == "production":
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["*"] # Configure based on your needs
)
# Add custom middleware
app.add_middleware(ErrorHandlerMiddleware)
app.add_middleware(LoggingMiddleware)
if settings.rate_limit.enabled:
app.add_middleware(
RateLimitMiddleware,
settings=settings.rate_limit
)
# Include routers
app.include_router(
health_router,
prefix="/health",
tags=["health"]
)
app.include_router(
auth_router,
prefix=f"{settings.app.api_prefix}/auth",
tags=["authentication"]
)
app.include_router(
application_router,
prefix=f"{settings.app.api_prefix}/applications",
tags=["applications"]
)
app.include_router(
attachment_router,
prefix=f"{settings.app.api_prefix}/attachments",
tags=["attachments"]
)
app.include_router(
pdf_router,
prefix=f"{settings.app.api_prefix}/pdf",
tags=["pdf"]
)
# Root endpoint
@app.get("/", tags=["root"])
async def root() -> Dict[str, Any]:
"""Root endpoint returning API information"""
return {
"name": settings.app.app_name,
"version": settings.app.app_version,
"environment": settings.app.environment,
"docs": settings.app.docs_url,
"health": "/health"
}
# Global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Handle uncaught exceptions"""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
if settings.app.debug:
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"detail": str(exc),
"type": type(exc).__name__
}
)
else:
return JSONResponse(
status_code=500,
content={"error": "Internal server error"}
)
return app
# Create app instance
app = create_app()
# For development server
if __name__ == "__main__":
import uvicorn
settings = get_settings()
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=settings.app.environment == "development",
log_level=settings.app.log_level.lower()
)

View File

@ -0,0 +1,379 @@
"""
Database migration for OIDC authentication, user management, and form templates
This migration adds support for:
- User authentication with OIDC and email
- Role-based access control
- PDF template management
- Enhanced application workflow
- Voting and review system
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from datetime import datetime
# Revision identifiers
revision = '001_add_oidc_and_templates'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
"""Apply migration changes"""
# Create users table
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(255), nullable=False),
sa.Column('auth_provider', sa.Enum('LOCAL', 'OIDC', 'EMAIL', name='authprovider'), nullable=False),
sa.Column('oidc_sub', sa.String(255), nullable=True),
sa.Column('oidc_issuer', sa.String(255), nullable=True),
sa.Column('given_name', sa.String(255), nullable=True),
sa.Column('family_name', sa.String(255), nullable=True),
sa.Column('preferred_username', sa.String(255), nullable=True),
sa.Column('display_name', sa.String(255), nullable=True),
sa.Column('picture_url', sa.Text(), nullable=True),
sa.Column('verification_status', sa.Enum('UNVERIFIED', 'EMAIL_VERIFIED', 'OIDC_VERIFIED', 'FULLY_VERIFIED', name='verificationstatus'), nullable=False),
sa.Column('email_verified', sa.Boolean(), nullable=True, default=False),
sa.Column('email_verification_token', sa.String(255), nullable=True),
sa.Column('email_verification_sent_at', sa.DateTime(), nullable=True),
sa.Column('email_verified_at', sa.DateTime(), nullable=True),
sa.Column('last_login_at', sa.DateTime(), nullable=True),
sa.Column('last_activity_at', sa.DateTime(), nullable=True),
sa.Column('oidc_access_token', sa.Text(), nullable=True),
sa.Column('oidc_refresh_token', sa.Text(), nullable=True),
sa.Column('oidc_token_expires_at', sa.DateTime(), nullable=True),
sa.Column('oidc_claims', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
comment='User accounts with OIDC support'
)
op.create_index('idx_user_email', 'users', ['email'])
op.create_index('idx_user_oidc_sub', 'users', ['oidc_sub'])
op.create_index('idx_user_email_provider', 'users', ['email', 'auth_provider'])
op.create_index('idx_user_verification', 'users', ['verification_status', 'email_verified'])
# Create roles table
op.create_table(
'roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('display_name', sa.String(255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('oidc_role_claim', sa.String(255), nullable=True),
sa.Column('permissions', sa.JSON(), nullable=False, default=list),
sa.Column('is_system', sa.Boolean(), nullable=True, default=False),
sa.Column('is_admin', sa.Boolean(), nullable=True, default=False),
sa.Column('can_review_budget', sa.Boolean(), nullable=True, default=False),
sa.Column('can_review_finance', sa.Boolean(), nullable=True, default=False),
sa.Column('can_vote', sa.Boolean(), nullable=True, default=False),
sa.Column('priority', sa.Integer(), nullable=True, default=0),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'),
sa.UniqueConstraint('oidc_role_claim'),
comment='User roles for permission management'
)
op.create_index('idx_role_name', 'roles', ['name'])
op.create_index('idx_role_oidc_claim', 'roles', ['oidc_role_claim'])
# Create user_roles association table
op.create_table(
'user_roles',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
sa.UniqueConstraint('user_id', 'role_id', name='uq_user_role'),
comment='User-role associations'
)
# Create sessions table
op.create_table(
'sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('session_token', sa.String(255), nullable=False),
sa.Column('refresh_token', sa.String(255), nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_activity_at', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('session_token'),
sa.UniqueConstraint('refresh_token'),
comment='User sessions for tracking active logins'
)
op.create_index('idx_session_user', 'sessions', ['user_id'])
op.create_index('idx_session_token', 'sessions', ['session_token'])
op.create_index('idx_session_expires', 'sessions', ['expires_at'])
# Create form_templates table
op.create_table(
'form_templates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('display_name', sa.String(255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('form_type', sa.Enum('QSM', 'VSM', 'CUSTOM', name='formtype'), nullable=False),
sa.Column('pdf_file_path', sa.String(500), nullable=True),
sa.Column('pdf_file_name', sa.String(255), nullable=True),
sa.Column('pdf_file_size', sa.Integer(), nullable=True),
sa.Column('pdf_file_hash', sa.String(64), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('is_public', sa.Boolean(), nullable=True, default=True),
sa.Column('requires_verification', sa.Boolean(), nullable=True, default=True),
sa.Column('allowed_roles', sa.JSON(), nullable=True),
sa.Column('form_design', sa.JSON(), nullable=True),
sa.Column('workflow_config', sa.JSON(), nullable=True),
sa.Column('usage_count', sa.Integer(), nullable=True, default=0),
sa.Column('version', sa.String(20), nullable=True, default='1.0.0'),
sa.Column('parent_template_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['parent_template_id'], ['form_templates.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'),
comment='Form templates for configurable PDF forms'
)
op.create_index('idx_template_name', 'form_templates', ['name'])
op.create_index('idx_template_active_public', 'form_templates', ['is_active', 'is_public'])
op.create_index('idx_template_type_active', 'form_templates', ['form_type', 'is_active'])
# Create field_mappings table
op.create_table(
'field_mappings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('template_id', sa.Integer(), nullable=False),
sa.Column('pdf_field_name', sa.String(255), nullable=False),
sa.Column('pdf_field_type', sa.String(50), nullable=True),
sa.Column('field_key', sa.String(255), nullable=False),
sa.Column('field_label', sa.String(255), nullable=False),
sa.Column('field_type', sa.Enum('TEXT', 'NUMBER', 'DATE', 'EMAIL', 'PHONE', 'CHECKBOX', 'RADIO', 'SELECT', 'TEXTAREA', 'FILE', 'SIGNATURE', 'CURRENCY', name='fieldtype'), nullable=False),
sa.Column('field_order', sa.Integer(), nullable=True, default=0),
sa.Column('is_required', sa.Boolean(), nullable=True, default=False),
sa.Column('is_readonly', sa.Boolean(), nullable=True, default=False),
sa.Column('is_hidden', sa.Boolean(), nullable=True, default=False),
sa.Column('is_email_field', sa.Boolean(), nullable=True, default=False),
sa.Column('is_name_field', sa.Boolean(), nullable=True, default=False),
sa.Column('validation_rules', sa.JSON(), nullable=True),
sa.Column('field_options', sa.JSON(), nullable=True),
sa.Column('default_value', sa.Text(), nullable=True),
sa.Column('placeholder', sa.String(500), nullable=True),
sa.Column('help_text', sa.Text(), nullable=True),
sa.Column('display_conditions', sa.JSON(), nullable=True),
sa.Column('transform_rules', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['template_id'], ['form_templates.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('template_id', 'pdf_field_name', name='uq_template_pdf_field'),
sa.UniqueConstraint('template_id', 'field_key', name='uq_template_field_key'),
comment='Field mappings for PDF form fields'
)
op.create_index('idx_field_template', 'field_mappings', ['template_id'])
op.create_index('idx_field_template_order', 'field_mappings', ['template_id', 'field_order'])
# Create form_designs table
op.create_table(
'form_designs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('template_id', sa.Integer(), nullable=False),
sa.Column('layout_type', sa.String(50), nullable=True, default='single-column'),
sa.Column('sections', sa.JSON(), nullable=False),
sa.Column('theme', sa.JSON(), nullable=True),
sa.Column('custom_css', sa.Text(), nullable=True),
sa.Column('components', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['template_id'], ['form_templates.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('template_id'),
comment='Visual form designer configuration'
)
# Modify existing applications table
if op.get_bind().dialect.has_table(op.get_bind(), 'applications'):
# Add new columns to applications table
op.add_column('applications', sa.Column('user_id', sa.Integer(), nullable=True))
op.add_column('applications', sa.Column('template_id', sa.Integer(), nullable=True))
op.add_column('applications', sa.Column('locked_at', sa.DateTime(), nullable=True))
op.add_column('applications', sa.Column('locked_by', sa.Integer(), nullable=True))
op.add_column('applications', sa.Column('budget_reviewed_by', sa.Integer(), nullable=True))
op.add_column('applications', sa.Column('budget_reviewed_at', sa.DateTime(), nullable=True))
op.add_column('applications', sa.Column('budget_review_status', sa.String(50), nullable=True))
op.add_column('applications', sa.Column('budget_review_comment', sa.Text(), nullable=True))
op.add_column('applications', sa.Column('finance_reviewed_by', sa.Integer(), nullable=True))
op.add_column('applications', sa.Column('finance_reviewed_at', sa.DateTime(), nullable=True))
op.add_column('applications', sa.Column('finance_review_status', sa.String(50), nullable=True))
op.add_column('applications', sa.Column('finance_review_comment', sa.Text(), nullable=True))
op.add_column('applications', sa.Column('voting_opened_at', sa.DateTime(), nullable=True))
op.add_column('applications', sa.Column('voting_closed_at', sa.DateTime(), nullable=True))
op.add_column('applications', sa.Column('voting_result', sa.String(50), nullable=True))
op.add_column('applications', sa.Column('votes_for', sa.Integer(), nullable=True, default=0))
op.add_column('applications', sa.Column('votes_against', sa.Integer(), nullable=True, default=0))
op.add_column('applications', sa.Column('votes_abstain', sa.Integer(), nullable=True, default=0))
# Create foreign key constraints
op.create_foreign_key('fk_app_user', 'applications', 'users', ['user_id'], ['id'])
op.create_foreign_key('fk_app_template', 'applications', 'form_templates', ['template_id'], ['id'])
op.create_foreign_key('fk_app_locker', 'applications', 'users', ['locked_by'], ['id'])
op.create_foreign_key('fk_app_budget_reviewer', 'applications', 'users', ['budget_reviewed_by'], ['id'])
op.create_foreign_key('fk_app_finance_reviewer', 'applications', 'users', ['finance_reviewed_by'], ['id'])
# Create indexes
op.create_index('idx_app_user', 'applications', ['user_id'])
op.create_index('idx_app_template', 'applications', ['template_id'])
# Create application_votes table
op.create_table(
'application_votes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('application_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('vote', sa.String(20), nullable=False),
sa.Column('comment', sa.Text(), nullable=True),
sa.Column('voted_at', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['application_id'], ['applications.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('application_id', 'user_id', name='uq_application_user_vote'),
comment='Application voting records'
)
op.create_index('idx_vote_application', 'application_votes', ['application_id'])
op.create_index('idx_vote_user', 'application_votes', ['user_id'])
# Create application_history table
op.create_table(
'application_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('application_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('action', sa.String(100), nullable=False),
sa.Column('old_status', sa.String(50), nullable=True),
sa.Column('new_status', sa.String(50), nullable=True),
sa.Column('changes', sa.JSON(), nullable=True),
sa.Column('comment', sa.Text(), nullable=True),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['application_id'], ['applications.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
comment='Application history tracking'
)
op.create_index('idx_history_application', 'application_history', ['application_id'])
op.create_index('idx_history_user', 'application_history', ['user_id'])
op.create_index('idx_history_timestamp', 'application_history', ['timestamp'])
# Create application_attachments table
op.create_table(
'application_attachments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('application_id', sa.Integer(), nullable=False),
sa.Column('file_name', sa.String(255), nullable=False),
sa.Column('file_path', sa.String(500), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('file_type', sa.String(100), nullable=True),
sa.Column('file_hash', sa.String(64), nullable=True),
sa.Column('uploaded_by', sa.Integer(), nullable=True),
sa.Column('uploaded_at', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['application_id'], ['applications.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
comment='Application attachment files'
)
op.create_index('idx_attachment_application', 'application_attachments', ['application_id'])
# Insert default roles
op.execute("""
INSERT INTO roles (name, display_name, description, is_system, permissions)
VALUES
('admin', 'Administrator', 'Full system access', TRUE, '["*"]'),
('user', 'User', 'Basic user access', TRUE, '["read:own", "write:own"]'),
('haushaltsbeauftragte', 'Haushaltsbeauftragte(r)', 'Budget reviewer', FALSE, '["review:budget", "read:applications"]'),
('finanzreferent', 'Finanzreferent', 'Finance reviewer', FALSE, '["review:finance", "read:applications"]'),
('asta', 'AStA Member', 'Can vote on applications', FALSE, '["vote:applications", "read:applications"]')
""")
# Update application status enum
op.execute("""
ALTER TABLE applications
MODIFY COLUMN status ENUM('draft', 'beantragt', 'bearbeitung_gesperrt', 'zu_pruefen', 'zur_abstimmung', 'genehmigt', 'abgelehnt', 'cancelled')
""")
def downgrade():
"""Revert migration changes"""
# Drop new tables
op.drop_table('application_attachments')
op.drop_table('application_history')
op.drop_table('application_votes')
op.drop_table('form_designs')
op.drop_table('field_mappings')
op.drop_table('form_templates')
op.drop_table('sessions')
op.drop_table('user_roles')
op.drop_table('roles')
op.drop_table('users')
# Remove foreign keys from applications table
if op.get_bind().dialect.has_table(op.get_bind(), 'applications'):
op.drop_constraint('fk_app_user', 'applications', type_='foreignkey')
op.drop_constraint('fk_app_template', 'applications', type_='foreignkey')
op.drop_constraint('fk_app_locker', 'applications', type_='foreignkey')
op.drop_constraint('fk_app_budget_reviewer', 'applications', type_='foreignkey')
op.drop_constraint('fk_app_finance_reviewer', 'applications', type_='foreignkey')
# Drop indexes
op.drop_index('idx_app_user', 'applications')
op.drop_index('idx_app_template', 'applications')
# Drop columns
op.drop_column('applications', 'user_id')
op.drop_column('applications', 'template_id')
op.drop_column('applications', 'locked_at')
op.drop_column('applications', 'locked_by')
op.drop_column('applications', 'budget_reviewed_by')
op.drop_column('applications', 'budget_reviewed_at')
op.drop_column('applications', 'budget_review_status')
op.drop_column('applications', 'budget_review_comment')
op.drop_column('applications', 'finance_reviewed_by')
op.drop_column('applications', 'finance_reviewed_at')
op.drop_column('applications', 'finance_review_status')
op.drop_column('applications', 'finance_review_comment')
op.drop_column('applications', 'voting_opened_at')
op.drop_column('applications', 'voting_closed_at')
op.drop_column('applications', 'voting_result')
op.drop_column('applications', 'votes_for')
op.drop_column('applications', 'votes_against')
op.drop_column('applications', 'votes_abstain')
# Revert status enum
op.execute("""
ALTER TABLE applications
MODIFY COLUMN status ENUM('draft', 'submitted', 'in_review', 'approved', 'rejected', 'cancelled', 'completed')
""")

View File

@ -0,0 +1,620 @@
"""
Application Database Models
This module defines the database models for the application system.
"""
from sqlalchemy import (
Column, Integer, String, Text, DateTime, JSON, Boolean,
ForeignKey, UniqueConstraint, Index, Float, Enum as SQLEnum
)
from sqlalchemy.orm import relationship, backref
from sqlalchemy.dialects.mysql import LONGTEXT
import enum
from typing import Optional, Dict, Any
from datetime import datetime
from .base import ExtendedBaseModel, BaseModel, TimestampMixin
class ApplicationStatus(enum.Enum):
"""Application status enumeration"""
DRAFT = "draft"
BEANTRAGT = "beantragt" # Submitted
BEARBEITUNG_GESPERRT = "bearbeitung_gesperrt" # Processing locked
ZU_PRUEFEN = "zu_pruefen" # To be reviewed
ZUR_ABSTIMMUNG = "zur_abstimmung" # For voting
GENEHMIGT = "genehmigt" # Approved
ABGELEHNT = "abgelehnt" # Rejected
CANCELLED = "cancelled"
class ApplicationType(enum.Enum):
"""Application type enumeration"""
QSM = "QSM"
VSM = "VSM"
class InstitutionType(enum.Enum):
"""Institution type enumeration"""
STUDENT_FS = "stud-fs"
STUDENT_RF = "stud-rf"
STUDENT_HG = "stud-hg"
FACULTY = "faculty"
HS_INSTITUTION = "hs-institution"
class Application(ExtendedBaseModel):
"""Main application model"""
__tablename__ = "applications"
# Core fields
pa_id = Column(
String(64),
unique=True,
nullable=False,
index=True,
comment="Public application ID"
)
pa_key = Column(
String(255),
nullable=False,
comment="Application access key (hashed)"
)
# User relationship
user_id = Column(
Integer,
ForeignKey('users.id'),
nullable=True,
index=True,
comment="User who created the application"
)
# Template relationship
template_id = Column(
Integer,
ForeignKey('form_templates.id'),
nullable=True,
index=True,
comment="Form template used"
)
variant = Column(
SQLEnum(ApplicationType),
nullable=False,
default=ApplicationType.QSM,
comment="Application variant (QSM or VSM)"
)
status = Column(
SQLEnum(ApplicationStatus),
nullable=False,
default=ApplicationStatus.DRAFT,
index=True,
comment="Application status"
)
# JSON payload containing all form data
payload = Column(
JSON,
nullable=False,
default=dict,
comment="Complete application payload"
)
# Searchable extracted fields for quick queries
institution_name = Column(String(255), index=True)
institution_type = Column(SQLEnum(InstitutionType), index=True)
applicant_first_name = Column(String(255), index=True)
applicant_last_name = Column(String(255), index=True)
applicant_email = Column(String(255), index=True)
project_name = Column(String(500), index=True)
project_start_date = Column(String(50))
project_end_date = Column(String(50))
total_amount = Column(Float, default=0.0)
class ApplicationVote(ExtendedBaseModel):
"""Application voting model"""
__tablename__ = "application_votes"
application_id = Column(
Integer,
ForeignKey('applications.id', ondelete='CASCADE'),
nullable=False,
index=True
)
user_id = Column(
Integer,
ForeignKey('users.id', ondelete='CASCADE'),
nullable=False,
index=True
)
vote = Column(
String(20),
nullable=False,
comment="Vote: for, against, abstain"
)
comment = Column(
Text,
nullable=True,
comment="Optional vote comment"
)
voted_at = Column(
DateTime,
nullable=False,
default=datetime.utcnow
)
# Relationships
application = relationship("Application", back_populates="votes")
user = relationship("User")
# Unique constraint
__table_args__ = (
UniqueConstraint('application_id', 'user_id', name='uq_application_user_vote'),
)
class ApplicationHistory(ExtendedBaseModel):
"""Application history tracking"""
__tablename__ = "application_history"
application_id = Column(
Integer,
ForeignKey('applications.id', ondelete='CASCADE'),
nullable=False,
index=True
)
user_id = Column(
Integer,
ForeignKey('users.id'),
nullable=True,
index=True
)
action = Column(
String(100),
nullable=False,
comment="Action performed"
)
old_status = Column(
String(50),
nullable=True,
comment="Previous status"
)
new_status = Column(
String(50),
nullable=True,
comment="New status"
)
changes = Column(
JSON,
nullable=True,
default=dict,
comment="Field changes"
)
comment = Column(
Text,
nullable=True,
comment="History comment"
)
timestamp = Column(
DateTime,
nullable=False,
default=datetime.utcnow,
index=True
)
# Relationships
application = relationship("Application", back_populates="history")
user = relationship("User")
class ApplicationAttachment(ExtendedBaseModel):
"""Application attachment model"""
__tablename__ = "application_attachments"
application_id = Column(
Integer,
ForeignKey('applications.id', ondelete='CASCADE'),
nullable=False,
index=True
)
file_name = Column(
String(255),
nullable=False,
comment="Original file name"
)
file_path = Column(
String(500),
nullable=False,
comment="Storage path"
)
file_size = Column(
Integer,
nullable=False,
comment="File size in bytes"
)
file_type = Column(
String(100),
nullable=True,
comment="MIME type"
)
file_hash = Column(
String(64),
nullable=True,
comment="File SHA256 hash"
)
uploaded_by = Column(
Integer,
ForeignKey('users.id'),
nullable=True
)
uploaded_at = Column(
DateTime,
nullable=False,
default=datetime.utcnow
)
# Relationships
application = relationship("Application", back_populates="attachments")
uploader = relationship("User")
# Metadata
submitted_at = Column(DateTime, nullable=True)
reviewed_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
# Workflow fields
locked_at = Column(DateTime, nullable=True, comment="When processing was locked")
locked_by = Column(Integer, ForeignKey('users.id'), nullable=True, comment="Who locked the processing")
# Budget review
budget_reviewed_by = Column(Integer, ForeignKey('users.id'), nullable=True, comment="Haushaltsbeauftragte")
budget_reviewed_at = Column(DateTime, nullable=True)
budget_review_status = Column(String(50), nullable=True) # approved, rejected, pending
budget_review_comment = Column(Text, nullable=True)
# Finance review
finance_reviewed_by = Column(Integer, ForeignKey('users.id'), nullable=True, comment="Finanzreferent")
finance_reviewed_at = Column(DateTime, nullable=True)
finance_review_status = Column(String(50), nullable=True) # approved, rejected, pending
finance_review_comment = Column(Text, nullable=True)
# Voting
voting_opened_at = Column(DateTime, nullable=True)
voting_closed_at = Column(DateTime, nullable=True)
voting_result = Column(String(50), nullable=True) # approved, rejected
votes_for = Column(Integer, default=0)
votes_against = Column(Integer, default=0)
votes_abstain = Column(Integer, default=0)
# Relationships
user = relationship("User", foreign_keys=[user_id], back_populates="applications")
template = relationship("FormTemplate", back_populates="applications")
locker = relationship("User", foreign_keys=[locked_by])
budget_reviewer = relationship("User", foreign_keys=[budget_reviewed_by])
finance_reviewer = relationship("User", foreign_keys=[finance_reviewed_by])
votes = relationship("ApplicationVote", back_populates="application", cascade="all, delete-orphan")
attachments = relationship("ApplicationAttachment", back_populates="application", cascade="all, delete-orphan")
history = relationship("ApplicationHistory", back_populates="application", cascade="all, delete-orphan")
reviewed_by = Column(String(255), nullable=True)
# PDF storage
pdf_data = Column(
LONGTEXT,
nullable=True,
comment="Base64 encoded PDF data"
)
pdf_generated_at = Column(DateTime, nullable=True)
# Relationships
attachments = relationship(
"ApplicationAttachment",
back_populates="application",
cascade="all, delete-orphan",
lazy="dynamic"
)
comparison_offers = relationship(
"ComparisonOffer",
back_populates="application",
cascade="all, delete-orphan",
lazy="dynamic"
)
cost_justifications = relationship(
"CostPositionJustification",
back_populates="application",
cascade="all, delete-orphan",
lazy="dynamic"
)
# Indexes
__table_args__ = (
Index("idx_app_status_created", "status", "created_at"),
Index("idx_app_email_status", "applicant_email", "status"),
Index("idx_app_institution", "institution_type", "institution_name"),
Index("idx_app_dates", "project_start_date", "project_end_date"),
)
def update_from_payload(self):
"""Update searchable fields from payload"""
if not self.payload:
return
pa = self.payload.get("pa", {})
# Extract applicant info
applicant = pa.get("applicant", {})
self.applicant_first_name = applicant.get("name", {}).get("first")
self.applicant_last_name = applicant.get("name", {}).get("last")
self.applicant_email = applicant.get("contact", {}).get("email")
# Extract institution info
institution = applicant.get("institution", {})
self.institution_name = institution.get("name")
inst_type = institution.get("type")
if inst_type and inst_type != "-":
try:
self.institution_type = InstitutionType(inst_type)
except ValueError:
pass
# Extract project info
project = pa.get("project", {})
self.project_name = project.get("name")
dates = project.get("dates", {})
self.project_start_date = dates.get("start")
self.project_end_date = dates.get("end")
# Calculate total amount
costs = project.get("costs", [])
total = 0.0
for cost in costs:
amount = cost.get("amountEur", 0)
if amount:
total += float(amount)
self.total_amount = total
def to_dict(self, exclude: Optional[set] = None, include_pdf: bool = False) -> Dict[str, Any]:
"""Convert to dictionary with optional PDF exclusion"""
exclude = exclude or set()
if not include_pdf:
exclude.add("pdf_data")
data = super().to_dict(exclude=exclude)
# Convert enums to strings
if "status" in data and data["status"]:
data["status"] = data["status"].value if hasattr(data["status"], "value") else data["status"]
if "variant" in data and data["variant"]:
data["variant"] = data["variant"].value if hasattr(data["variant"], "value") else data["variant"]
if "institution_type" in data and data["institution_type"]:
data["institution_type"] = data["institution_type"].value if hasattr(data["institution_type"], "value") else data["institution_type"]
return data
class Attachment(BaseModel, TimestampMixin):
"""Base attachment model"""
__tablename__ = "attachments"
id = Column(Integer, primary_key=True)
filename = Column(String(255), nullable=False)
content_type = Column(String(100), nullable=False)
size = Column(Integer, nullable=False)
checksum = Column(String(64), nullable=True)
storage_type = Column(
String(50),
default="database",
comment="Storage type: database or filesystem"
)
storage_path = Column(
String(500),
nullable=True,
comment="Path if stored in filesystem"
)
data = Column(
LONGTEXT,
nullable=True,
comment="Base64 encoded data if stored in database"
)
# Indexes
__table_args__ = (
Index("idx_attachment_checksum", "checksum"),
)
class ApplicationAttachment(BaseModel):
"""Junction table for application attachments with additional metadata"""
__tablename__ = "application_attachments"
id = Column(Integer, primary_key=True)
application_id = Column(
Integer,
ForeignKey("applications.id", ondelete="CASCADE"),
nullable=False,
index=True
)
attachment_id = Column(
Integer,
ForeignKey("attachments.id", ondelete="CASCADE"),
nullable=False,
index=True
)
category = Column(
String(50),
nullable=True,
comment="Attachment category (e.g., invoice, receipt, etc.)"
)
description = Column(Text, nullable=True)
uploaded_at = Column(DateTime, default=datetime.utcnow)
uploaded_by = Column(String(255), nullable=True)
# Relationships
application = relationship("Application", back_populates="attachments")
attachment = relationship("Attachment", backref="applications", cascade="all, delete")
# Constraints
__table_args__ = (
UniqueConstraint("application_id", "attachment_id", name="uq_app_attachment"),
Index("idx_app_attach_category", "application_id", "category"),
)
class ComparisonOffer(ExtendedBaseModel):
"""Comparison offers for cost positions"""
__tablename__ = "comparison_offers"
application_id = Column(
Integer,
ForeignKey("applications.id", ondelete="CASCADE"),
nullable=False,
index=True
)
cost_position_idx = Column(
Integer,
nullable=False,
comment="Index of cost position in application"
)
supplier_name = Column(String(255), nullable=False)
amount_eur = Column(Float, nullable=False)
description = Column(Text, nullable=True)
is_preferred = Column(
Boolean,
default=False,
comment="Whether this is the preferred offer"
)
attachment_id = Column(
Integer,
ForeignKey("attachments.id", ondelete="SET NULL"),
nullable=True
)
# Relationships
application = relationship("Application", back_populates="comparison_offers")
attachment = relationship("Attachment", backref="comparison_offers")
# Indexes
__table_args__ = (
Index("idx_comp_offer_app_pos", "application_id", "cost_position_idx"),
Index("idx_comp_offer_preferred", "application_id", "is_preferred"),
)
def to_dict(self, exclude: Optional[set] = None) -> Dict[str, Any]:
"""Convert to dictionary"""
data = super().to_dict(exclude=exclude)
# Include attachment info if available
if self.attachment:
data["attachment_info"] = {
"id": self.attachment.id,
"filename": self.attachment.filename,
"size": self.attachment.size,
"content_type": self.attachment.content_type
}
return data
class CostPositionJustification(ExtendedBaseModel):
"""Justifications for cost positions"""
__tablename__ = "cost_position_justifications"
application_id = Column(
Integer,
ForeignKey("applications.id", ondelete="CASCADE"),
nullable=False,
index=True
)
cost_position_idx = Column(
Integer,
nullable=False,
comment="Index of cost position in application"
)
justification = Column(
Text,
nullable=False,
comment="Justification text"
)
justification_type = Column(
String(50),
default="standard",
comment="Type of justification"
)
# Relationships
application = relationship("Application", back_populates="cost_justifications")
# Constraints
__table_args__ = (
UniqueConstraint(
"application_id", "cost_position_idx",
name="uq_app_cost_justification"
),
Index("idx_cost_just_type", "justification_type"),
)
class Counter(BaseModel):
"""Counter for generating sequential IDs"""
__tablename__ = "counters"
key = Column(String(50), unique=True, nullable=False)
value = Column(Integer, default=0, nullable=False)
prefix = Column(String(20), nullable=True)
suffix = Column(String(20), nullable=True)
format_string = Column(
String(100),
default="{prefix}{value:06d}{suffix}",
comment="Python format string for ID generation"
)
@classmethod
def get_next_value(cls, session, key: str, increment: int = 1) -> int:
"""Get next counter value with atomic increment"""
counter = session.query(cls).filter_by(key=key).with_for_update().first()
if not counter:
counter = cls(key=key, value=0)
session.add(counter)
counter.value += increment
session.flush()
return counter.value
def format_id(self, value: Optional[int] = None) -> str:
"""Format counter value as ID string"""
val = value if value is not None else self.value
return self.format_string.format(
prefix=self.prefix or "",
suffix=self.suffix or "",
value=val
)

257
backend/src/models/base.py Normal file
View File

@ -0,0 +1,257 @@
"""
Base Database Models
This module provides base classes and mixins for SQLAlchemy models.
All database models should inherit from these base classes.
"""
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import Column, Integer, DateTime, String, Boolean, func
from sqlalchemy.orm import declarative_base, declared_attr
from sqlalchemy.ext.declarative import DeclarativeMeta
import json
# Create base class for all models
Base = declarative_base()
class TimestampMixin:
"""Mixin that adds timestamp fields to models"""
@declared_attr
def created_at(cls):
return Column(
DateTime,
default=func.now(),
nullable=False,
comment="Record creation timestamp"
)
@declared_attr
def updated_at(cls):
return Column(
DateTime,
default=func.now(),
onupdate=func.now(),
nullable=False,
comment="Record last update timestamp"
)
class SoftDeleteMixin:
"""Mixin that adds soft delete functionality"""
@declared_attr
def is_deleted(cls):
return Column(
Boolean,
default=False,
nullable=False,
index=True,
comment="Soft delete flag"
)
@declared_attr
def deleted_at(cls):
return Column(
DateTime,
nullable=True,
comment="Soft delete timestamp"
)
def soft_delete(self):
"""Mark record as deleted"""
self.is_deleted = True
self.deleted_at = datetime.utcnow()
def restore(self):
"""Restore soft deleted record"""
self.is_deleted = False
self.deleted_at = None
class AuditMixin:
"""Mixin that adds audit fields"""
@declared_attr
def created_by(cls):
return Column(
String(255),
nullable=True,
comment="User who created the record"
)
@declared_attr
def updated_by(cls):
return Column(
String(255),
nullable=True,
comment="User who last updated the record"
)
@declared_attr
def version(cls):
return Column(
Integer,
default=1,
nullable=False,
comment="Record version for optimistic locking"
)
class BaseModel(Base):
"""
Base model class that includes common fields and methods.
All database models should inherit from this class.
"""
__abstract__ = True
id = Column(
Integer,
primary_key=True,
autoincrement=True,
comment="Primary key"
)
def to_dict(self, exclude: Optional[set] = None) -> Dict[str, Any]:
"""
Convert model instance to dictionary.
Args:
exclude: Set of field names to exclude from the result
Returns:
Dictionary representation of the model
"""
exclude = exclude or set()
result = {}
for column in self.__table__.columns:
if column.name not in exclude:
value = getattr(self, column.name)
# Handle datetime objects
if isinstance(value, datetime):
value = value.isoformat()
# Handle other non-serializable types
elif hasattr(value, "__dict__") and not isinstance(value, (str, int, float, bool, list, dict)):
continue
result[column.name] = value
return result
def from_dict(self, data: Dict[str, Any], exclude: Optional[set] = None) -> 'BaseModel':
"""
Update model instance from dictionary.
Args:
data: Dictionary with field values
exclude: Set of field names to exclude from update
Returns:
Self for method chaining
"""
exclude = exclude or set()
for key, value in data.items():
if key not in exclude and hasattr(self, key):
# Skip relationships and computed properties
if not hasattr(self.__class__.__dict__.get(key), "property"):
setattr(self, key, value)
return self
def __repr__(self):
"""String representation of the model"""
return f"<{self.__class__.__name__}(id={self.id})>"
@classmethod
def get_columns(cls) -> list:
"""Get list of column names"""
return [column.name for column in cls.__table__.columns]
@classmethod
def get_relationships(cls) -> list:
"""Get list of relationship names"""
return [
rel for rel in dir(cls)
if not rel.startswith("_") and
hasattr(getattr(cls, rel), "property") and
hasattr(getattr(cls, rel).property, "mapper")
]
class ExtendedBaseModel(BaseModel, TimestampMixin, SoftDeleteMixin, AuditMixin):
"""
Extended base model that includes all mixins.
Use this for models that need full audit trail and soft delete support.
"""
__abstract__ = True
def to_dict(self, exclude: Optional[set] = None, include_deleted: bool = False) -> Optional[Dict[str, Any]]:
"""
Convert model instance to dictionary with soft delete awareness.
Args:
exclude: Set of field names to exclude
include_deleted: Whether to include soft deleted records
Returns:
Dictionary representation or None if deleted and not including deleted
"""
if not include_deleted and self.is_deleted:
return None
return super().to_dict(exclude=exclude)
def to_json(self, exclude: Optional[set] = None, include_deleted: bool = False) -> str:
"""
Convert model instance to JSON string.
Args:
exclude: Set of field names to exclude
include_deleted: Whether to include soft deleted records
Returns:
JSON string representation
"""
data = self.to_dict(exclude=exclude, include_deleted=include_deleted)
if data is None:
return "null"
return json.dumps(data, ensure_ascii=False, default=str)
class JSONEncodedDict(dict):
"""Custom type for JSON columns that automatically handles encoding/decoding"""
def __init__(self, data=None):
if isinstance(data, str):
super().__init__(json.loads(data) if data else {})
elif data is None:
super().__init__()
else:
super().__init__(data)
def create_model_registry():
"""
Create a registry of all models for dynamic access.
Returns:
Dictionary mapping model names to model classes
"""
registry = {}
for mapper in Base.registry.mappers:
model = mapper.class_
registry[model.__name__] = model
# Also register by table name
if hasattr(model, "__tablename__"):
registry[model.__tablename__] = model
return registry

View File

@ -0,0 +1,458 @@
"""
Form Template Database Models
This module defines the database models for form templates and PDF field mappings.
"""
from sqlalchemy import (
Column, Integer, String, Text, DateTime, JSON, Boolean,
ForeignKey, UniqueConstraint, Index, Enum as SQLEnum
)
from sqlalchemy.orm import relationship, backref
import enum
from typing import Optional, Dict, Any, List
from datetime import datetime
from .base import ExtendedBaseModel, BaseModel, TimestampMixin
class FormType(enum.Enum):
"""Form type enumeration"""
QSM = "QSM"
VSM = "VSM"
CUSTOM = "CUSTOM"
class FieldType(enum.Enum):
"""Field type enumeration"""
TEXT = "text"
NUMBER = "number"
DATE = "date"
EMAIL = "email"
PHONE = "phone"
CHECKBOX = "checkbox"
RADIO = "radio"
SELECT = "select"
TEXTAREA = "textarea"
FILE = "file"
SIGNATURE = "signature"
CURRENCY = "currency"
class FormTemplate(ExtendedBaseModel):
"""Form template model for configurable PDF forms"""
__tablename__ = "form_templates"
# Core fields
name = Column(
String(255),
unique=True,
nullable=False,
index=True,
comment="Template name"
)
display_name = Column(
String(255),
nullable=False,
comment="Display name for UI"
)
description = Column(
Text,
nullable=True,
comment="Template description"
)
form_type = Column(
SQLEnum(FormType),
nullable=False,
default=FormType.CUSTOM,
index=True,
comment="Form type"
)
# PDF template file
pdf_file_path = Column(
String(500),
nullable=True,
comment="Path to uploaded PDF template file"
)
pdf_file_name = Column(
String(255),
nullable=True,
comment="Original PDF file name"
)
pdf_file_size = Column(
Integer,
nullable=True,
comment="PDF file size in bytes"
)
pdf_file_hash = Column(
String(64),
nullable=True,
comment="PDF file SHA256 hash"
)
# Form configuration
is_active = Column(
Boolean,
default=True,
index=True,
comment="Whether template is active"
)
is_public = Column(
Boolean,
default=True,
comment="Whether template is publicly accessible"
)
requires_verification = Column(
Boolean,
default=True,
comment="Whether user verification is required"
)
# Access control
allowed_roles = Column(
JSON,
nullable=True,
default=list,
comment="List of role names allowed to use this template"
)
# Form designer configuration
form_design = Column(
JSON,
nullable=True,
default=dict,
comment="Visual form designer configuration"
)
# Workflow configuration
workflow_config = Column(
JSON,
nullable=True,
default=dict,
comment="Workflow configuration for approval process"
)
# Statistics
usage_count = Column(
Integer,
default=0,
comment="Number of times template has been used"
)
# Version management
version = Column(
String(20),
default="1.0.0",
comment="Template version"
)
parent_template_id = Column(
Integer,
ForeignKey('form_templates.id'),
nullable=True,
comment="Parent template for versioning"
)
# Relationships
field_mappings = relationship(
"FieldMapping",
back_populates="template",
cascade="all, delete-orphan",
order_by="FieldMapping.field_order"
)
applications = relationship(
"Application",
back_populates="template"
)
parent_template = relationship(
"FormTemplate",
remote_side="FormTemplate.id",
backref=backref("versions", lazy="dynamic")
)
# Indexes
__table_args__ = (
Index('idx_template_active_public', 'is_active', 'is_public'),
Index('idx_template_type_active', 'form_type', 'is_active'),
)
def to_dict(self, include_mappings: bool = False) -> Dict[str, Any]:
"""Convert to dictionary representation"""
data = {
"id": self.id,
"name": self.name,
"display_name": self.display_name,
"description": self.description,
"form_type": self.form_type.value if self.form_type else None,
"is_active": self.is_active,
"is_public": self.is_public,
"requires_verification": self.requires_verification,
"allowed_roles": self.allowed_roles or [],
"version": self.version,
"usage_count": self.usage_count,
"pdf_file_name": self.pdf_file_name,
"pdf_file_size": self.pdf_file_size,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_mappings:
data["field_mappings"] = [
mapping.to_dict() for mapping in self.field_mappings
]
return data
class FieldMapping(ExtendedBaseModel):
"""Field mapping model for PDF form fields"""
__tablename__ = "field_mappings"
template_id = Column(
Integer,
ForeignKey('form_templates.id', ondelete='CASCADE'),
nullable=False,
index=True
)
# PDF field information
pdf_field_name = Column(
String(255),
nullable=False,
comment="Field name in PDF"
)
pdf_field_type = Column(
String(50),
nullable=True,
comment="Original PDF field type"
)
# Mapping configuration
field_key = Column(
String(255),
nullable=False,
comment="Internal field key for data storage"
)
field_label = Column(
String(255),
nullable=False,
comment="Display label for field"
)
field_type = Column(
SQLEnum(FieldType),
nullable=False,
default=FieldType.TEXT,
comment="Mapped field type"
)
field_order = Column(
Integer,
default=0,
comment="Field display order"
)
# Field configuration
is_required = Column(
Boolean,
default=False,
comment="Whether field is required"
)
is_readonly = Column(
Boolean,
default=False,
comment="Whether field is read-only"
)
is_hidden = Column(
Boolean,
default=False,
comment="Whether field is hidden"
)
# Special field flags
is_email_field = Column(
Boolean,
default=False,
comment="Whether this is the email field (auto-filled from user)"
)
is_name_field = Column(
Boolean,
default=False,
comment="Whether this is a name field (auto-filled from OIDC)"
)
# Validation rules
validation_rules = Column(
JSON,
nullable=True,
default=dict,
comment="Field validation rules"
)
# Options for select/radio fields
field_options = Column(
JSON,
nullable=True,
default=list,
comment="Options for select/radio/checkbox fields"
)
# Default value
default_value = Column(
Text,
nullable=True,
comment="Default field value"
)
# Placeholder and help text
placeholder = Column(
String(500),
nullable=True,
comment="Field placeholder text"
)
help_text = Column(
Text,
nullable=True,
comment="Field help text"
)
# Conditional display
display_conditions = Column(
JSON,
nullable=True,
default=dict,
comment="Conditions for displaying field"
)
# Data transformation
transform_rules = Column(
JSON,
nullable=True,
default=dict,
comment="Rules for transforming field data"
)
# Relationships
template = relationship(
"FormTemplate",
back_populates="field_mappings"
)
# Indexes
__table_args__ = (
UniqueConstraint('template_id', 'pdf_field_name', name='uq_template_pdf_field'),
UniqueConstraint('template_id', 'field_key', name='uq_template_field_key'),
Index('idx_field_template_order', 'template_id', 'field_order'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation"""
return {
"id": self.id,
"pdf_field_name": self.pdf_field_name,
"pdf_field_type": self.pdf_field_type,
"field_key": self.field_key,
"field_label": self.field_label,
"field_type": self.field_type.value if self.field_type else None,
"field_order": self.field_order,
"is_required": self.is_required,
"is_readonly": self.is_readonly,
"is_hidden": self.is_hidden,
"is_email_field": self.is_email_field,
"is_name_field": self.is_name_field,
"validation_rules": self.validation_rules or {},
"field_options": self.field_options or [],
"default_value": self.default_value,
"placeholder": self.placeholder,
"help_text": self.help_text,
"display_conditions": self.display_conditions or {},
"transform_rules": self.transform_rules or {},
}
class FormDesign(ExtendedBaseModel):
"""Visual form designer configuration"""
__tablename__ = "form_designs"
template_id = Column(
Integer,
ForeignKey('form_templates.id', ondelete='CASCADE'),
unique=True,
nullable=False,
index=True
)
# Designer layout
layout_type = Column(
String(50),
default="single-column",
comment="Layout type (single-column, two-column, custom)"
)
# Sections configuration
sections = Column(
JSON,
nullable=False,
default=list,
comment="Form sections configuration"
)
# Styling
theme = Column(
JSON,
nullable=True,
default=dict,
comment="Theme configuration"
)
custom_css = Column(
Text,
nullable=True,
comment="Custom CSS styles"
)
# Components configuration
components = Column(
JSON,
nullable=True,
default=list,
comment="Custom components configuration"
)
# Relationships
template = relationship(
"FormTemplate",
backref=backref("design", uselist=False, cascade="all, delete-orphan")
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation"""
return {
"id": self.id,
"template_id": self.template_id,
"layout_type": self.layout_type,
"sections": self.sections or [],
"theme": self.theme or {},
"custom_css": self.custom_css,
"components": self.components or [],
}

363
backend/src/models/user.py Normal file
View File

@ -0,0 +1,363 @@
"""
User Database Models with OIDC Support
This module defines the database models for users with OIDC/OAuth2 integration.
"""
from sqlalchemy import (
Column, Integer, String, Text, DateTime, JSON, Boolean,
ForeignKey, UniqueConstraint, Index, Table, Enum as SQLEnum
)
from sqlalchemy.orm import relationship, backref
import enum
from typing import Optional, Dict, Any, List
from datetime import datetime
from .base import ExtendedBaseModel, BaseModel, TimestampMixin
class AuthProvider(enum.Enum):
"""Authentication provider enumeration"""
LOCAL = "local"
OIDC = "oidc"
EMAIL = "email"
class VerificationStatus(enum.Enum):
"""Verification status enumeration"""
UNVERIFIED = "unverified"
EMAIL_VERIFIED = "email_verified"
OIDC_VERIFIED = "oidc_verified"
FULLY_VERIFIED = "fully_verified"
# Association table for user roles
user_roles = Table(
'user_roles',
BaseModel.metadata,
Column('user_id', Integer, ForeignKey('users.id', ondelete='CASCADE')),
Column('role_id', Integer, ForeignKey('roles.id', ondelete='CASCADE')),
UniqueConstraint('user_id', 'role_id', name='uq_user_role')
)
class User(ExtendedBaseModel):
"""User model with OIDC support"""
__tablename__ = "users"
# Core fields
email = Column(
String(255),
unique=True,
nullable=False,
index=True,
comment="User email address"
)
# Authentication fields
auth_provider = Column(
SQLEnum(AuthProvider),
nullable=False,
default=AuthProvider.LOCAL,
comment="Authentication provider"
)
oidc_sub = Column(
String(255),
nullable=True,
unique=True,
index=True,
comment="OIDC subject identifier"
)
oidc_issuer = Column(
String(255),
nullable=True,
comment="OIDC issuer URL"
)
# Profile fields from OIDC
given_name = Column(String(255), nullable=True, comment="Given name from OIDC")
family_name = Column(String(255), nullable=True, comment="Family name from OIDC")
preferred_username = Column(String(255), nullable=True, comment="Preferred username from OIDC")
display_name = Column(String(255), nullable=True, comment="Display name")
picture_url = Column(Text, nullable=True, comment="Profile picture URL")
# Verification
verification_status = Column(
SQLEnum(VerificationStatus),
nullable=False,
default=VerificationStatus.UNVERIFIED,
index=True,
comment="User verification status"
)
email_verified = Column(Boolean, default=False, comment="Email verification flag")
email_verification_token = Column(String(255), nullable=True, comment="Email verification token")
email_verification_sent_at = Column(DateTime, nullable=True, comment="When verification email was sent")
email_verified_at = Column(DateTime, nullable=True, comment="When email was verified")
# Session management
last_login_at = Column(DateTime, nullable=True, comment="Last login timestamp")
last_activity_at = Column(DateTime, nullable=True, comment="Last activity timestamp")
# OIDC tokens (encrypted)
oidc_access_token = Column(Text, nullable=True, comment="Encrypted OIDC access token")
oidc_refresh_token = Column(Text, nullable=True, comment="Encrypted OIDC refresh token")
oidc_token_expires_at = Column(DateTime, nullable=True, comment="OIDC token expiration")
# Additional OIDC claims
oidc_claims = Column(
JSON,
nullable=True,
default=dict,
comment="Additional OIDC claims"
)
# Relationships
roles = relationship(
"Role",
secondary=user_roles,
back_populates="users",
lazy="joined"
)
applications = relationship(
"Application",
back_populates="user",
cascade="all, delete-orphan"
)
# Indexes
__table_args__ = (
Index('idx_user_email_provider', 'email', 'auth_provider'),
Index('idx_user_verification', 'verification_status', 'email_verified'),
)
def has_role(self, role_name: str) -> bool:
"""Check if user has a specific role"""
return any(role.name == role_name for role in self.roles)
def has_any_role(self, role_names: List[str]) -> bool:
"""Check if user has any of the specified roles"""
user_roles = {role.name for role in self.roles}
return bool(user_roles.intersection(role_names))
def get_display_name(self) -> str:
"""Get the best available display name"""
if self.display_name:
return self.display_name
if self.given_name and self.family_name:
return f"{self.given_name} {self.family_name}"
if self.preferred_username:
return self.preferred_username
return self.email.split('@')[0]
def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
"""Convert to dictionary representation"""
data = {
"id": self.id,
"email": self.email,
"auth_provider": self.auth_provider.value if self.auth_provider else None,
"given_name": self.given_name,
"family_name": self.family_name,
"display_name": self.get_display_name(),
"picture_url": self.picture_url,
"verification_status": self.verification_status.value if self.verification_status else None,
"email_verified": self.email_verified,
"roles": [role.to_dict() for role in self.roles],
"last_login_at": self.last_login_at.isoformat() if self.last_login_at else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_sensitive:
data.update({
"oidc_sub": self.oidc_sub,
"oidc_issuer": self.oidc_issuer,
"oidc_claims": self.oidc_claims,
})
return data
class Role(ExtendedBaseModel):
"""Role model for permission management"""
__tablename__ = "roles"
name = Column(
String(100),
unique=True,
nullable=False,
index=True,
comment="Role name"
)
display_name = Column(
String(255),
nullable=False,
comment="Display name for UI"
)
description = Column(
Text,
nullable=True,
comment="Role description"
)
# OIDC role mapping
oidc_role_claim = Column(
String(255),
nullable=True,
unique=True,
index=True,
comment="OIDC role claim value to map to this role"
)
# Permissions as JSON
permissions = Column(
JSON,
nullable=False,
default=list,
comment="List of permission strings"
)
# System role flags
is_system = Column(
Boolean,
default=False,
comment="System role that cannot be deleted"
)
is_admin = Column(
Boolean,
default=False,
comment="Admin role with full access"
)
# Special role flags for application workflow
can_review_budget = Column(
Boolean,
default=False,
comment="Can review budget (Haushaltsbeauftragte)"
)
can_review_finance = Column(
Boolean,
default=False,
comment="Can review finance (Finanzreferent)"
)
can_vote = Column(
Boolean,
default=False,
comment="Can vote on applications (AStA member)"
)
# Priority for role assignment (higher = more important)
priority = Column(
Integer,
default=0,
comment="Role priority for conflicts"
)
# Relationships
users = relationship(
"User",
secondary=user_roles,
back_populates="roles"
)
def has_permission(self, permission: str) -> bool:
"""Check if role has a specific permission"""
if self.is_admin:
return True
return permission in (self.permissions or [])
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation"""
return {
"id": self.id,
"name": self.name,
"display_name": self.display_name,
"description": self.description,
"is_admin": self.is_admin,
"can_review_budget": self.can_review_budget,
"can_review_finance": self.can_review_finance,
"can_vote": self.can_vote,
"permissions": self.permissions or [],
}
class Session(ExtendedBaseModel):
"""User session model for tracking active sessions"""
__tablename__ = "sessions"
user_id = Column(
Integer,
ForeignKey('users.id', ondelete='CASCADE'),
nullable=False,
index=True
)
session_token = Column(
String(255),
unique=True,
nullable=False,
index=True,
comment="Session token"
)
refresh_token = Column(
String(255),
unique=True,
nullable=True,
index=True,
comment="Refresh token"
)
ip_address = Column(
String(45),
nullable=True,
comment="Client IP address"
)
user_agent = Column(
Text,
nullable=True,
comment="Client user agent"
)
expires_at = Column(
DateTime,
nullable=False,
index=True,
comment="Session expiration"
)
last_activity_at = Column(
DateTime,
nullable=False,
default=datetime.utcnow,
comment="Last activity timestamp"
)
# Relationships
user = relationship("User", backref=backref("sessions", cascade="all, delete-orphan"))
@property
def is_expired(self) -> bool:
"""Check if session is expired"""
return datetime.utcnow() > self.expires_at
@property
def is_active(self) -> bool:
"""Check if session is active"""
if self.is_expired:
return False
# Consider session inactive if no activity for 30 minutes
inactive_threshold = datetime.utcnow() - timedelta(minutes=30)
return self.last_activity_at > inactive_threshold

View File

@ -0,0 +1,400 @@
"""
QSM PDF Variant Provider
This module provides the PDF variant provider for QSM (Qualitätssicherungsmittel) forms.
"""
from typing import Dict, Any, List, Optional
from pathlib import Path
import re
import logging
from ..services.pdf import PDFVariantProvider
from ..config.settings import Settings, get_settings
logger = logging.getLogger(__name__)
class QSMProvider(PDFVariantProvider):
"""Provider for QSM PDF variant"""
def __init__(self, settings: Optional[Settings] = None):
"""Initialize QSM provider"""
self.settings = settings or get_settings()
self._field_mapping = self._initialize_field_mapping()
def get_variant_name(self) -> str:
"""Get the name of this variant"""
return "QSM"
def get_template_path(self) -> Path:
"""Get the path to the PDF template for this variant"""
return self.settings.pdf.qsm_template
def get_variant_indicators(self) -> List[str]:
"""Get list of field names that indicate this variant"""
return [
"pa-qsm-financing",
"pa-qsm-vwv-3-2-1-1",
"pa-qsm-vwv-3-2-1-2"
]
def _initialize_field_mapping(self) -> Dict[str, Any]:
"""Initialize field mapping configuration"""
return {
# Meta fields
"pa-id": {
"target": "pa.meta.id",
"type": "str",
"required": True
},
"pa-key": {
"target": "pa.meta.key",
"type": "str",
"required": True
},
# Applicant fields
"pa-institution-type": {
"target": "pa.applicant.institution.type",
"type": "enum",
"values": {
"stud-fs": "Fachschaft",
"stud-rf": "STUPA-Referat",
"stud-hg": "Studentische Hochschulgruppe",
"faculty": "Fakultät",
"hs-institution": "Hochschuleinrichtung"
}
},
"pa-institution": {
"target": "pa.applicant.institution.name",
"type": "str",
"required": True
},
"pa-first-name": {
"target": "pa.applicant.name.first",
"type": "str",
"required": True
},
"pa-last-name": {
"target": "pa.applicant.name.last",
"type": "str",
"required": True
},
"pa-email": {
"target": "pa.applicant.contact.email",
"type": "str",
"required": True
},
"pa-phone": {
"target": "pa.applicant.contact.phone",
"type": "str"
},
"pa-course": {
"target": "pa.applicant.course",
"type": "enum",
"values": ["INF", "ESB", "LS", "TEC", "TEX", "NXT"]
},
"pa-role": {
"target": "pa.applicant.role",
"type": "enum",
"values": [
"Student", "Professor", "Mitarbeiter",
"ASTA", "Referatsleitung", "Fachschaftsvorstand"
]
},
# Project fields
"pa-project-name": {
"target": "pa.project.name",
"type": "str",
"required": True
},
"pa-project-description": {
"target": "pa.project.description",
"type": "str",
"required": True
},
"pa-start-date": {
"target": "pa.project.dates.start",
"type": "str",
"required": True
},
"pa-end-date": {
"target": "pa.project.dates.end",
"type": "str"
},
"pa-participants": {
"target": "pa.project.participants",
"type": "int"
},
# QSM-specific fields
"pa-qsm-financing": {
"target": "pa.project.financing.qsm.code",
"type": "enum",
"values": {
"vwv-3-2-1-1": "Finanzierung zusätzlicher Lehr- und Seminarangebote",
"vwv-3-2-1-2": "Fachspezifische Studienprojekte",
"vwv-3-2-1-3": "Hochschuldidaktische Fort- und Weiterbildungsmaßnahmen",
"vwv-3-2-1-4": "Studentische Tutorien und Arbeitsgruppen",
"vwv-3-2-1-5": "Exkursionen",
"vwv-3-2-1-6": "Sonstige Maßnahmen"
},
"required": True
}
}
def get_field_mapping(self) -> Dict[str, Any]:
"""Get the field mapping configuration for this variant"""
return self._field_mapping
def parse_pdf_fields(self, pdf_fields: Dict[str, Any]) -> Dict[str, Any]:
"""Parse PDF form fields into a structured payload"""
payload = {
"pa": {
"meta": {},
"applicant": {
"name": {},
"contact": {},
"institution": {}
},
"project": {
"dates": {},
"costs": [],
"participation": {"faculties": {}},
"financing": {"qsm": {}}
}
}
}
# Process each field according to mapping
for field_name, field_value in pdf_fields.items():
if field_value is None or field_value == "":
continue
# Handle cost fields with wildcards
cost_match = re.match(r"pa-cost-(\d+)-(name|amount-euro)", field_name)
if cost_match:
idx = int(cost_match.group(1)) - 1
field_type = cost_match.group(2)
# Ensure costs array is large enough
while len(payload["pa"]["project"]["costs"]) <= idx:
payload["pa"]["project"]["costs"].append({})
if field_type == "name":
payload["pa"]["project"]["costs"][idx]["name"] = field_value
elif field_type == "amount-euro":
payload["pa"]["project"]["costs"][idx]["amountEur"] = self._parse_amount(field_value)
continue
# Handle participation checkboxes
if field_name.startswith("pa-participating-faculties-"):
faculty = field_name.replace("pa-participating-faculties-", "")
payload["pa"]["project"]["participation"]["faculties"][faculty] = self._parse_bool(field_value)
continue
# Handle QSM-specific checkboxes
if field_name.startswith("pa-qsm-vwv-"):
key = field_name.replace("pa-qsm-", "")
if "flags" not in payload["pa"]["project"]["financing"]["qsm"]:
payload["pa"]["project"]["financing"]["qsm"]["flags"] = {}
payload["pa"]["project"]["financing"]["qsm"]["flags"][key] = self._parse_bool(field_value)
continue
# Process regular mapped fields
if field_name in self._field_mapping:
mapping = self._field_mapping[field_name]
target_path = mapping["target"]
field_type = mapping.get("type", "str")
# Transform value based on type
transformed_value = self.transform_value(field_value, field_type)
# Handle enum values
if field_type == "enum" and "values" in mapping:
if isinstance(mapping["values"], dict):
# Map to display value if available
transformed_value = mapping["values"].get(field_value, field_value)
# Set value in payload using target path
self._set_nested_value(payload, target_path, transformed_value)
# Clean up empty costs
if payload["pa"]["project"]["costs"]:
payload["pa"]["project"]["costs"] = [
cost for cost in payload["pa"]["project"]["costs"]
if cost.get("name") or cost.get("amountEur")
]
return payload
def map_payload_to_fields(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Map a structured payload to PDF form fields"""
pdf_fields = {}
# Process regular fields
for field_name, mapping in self._field_mapping.items():
target_path = mapping["target"]
value = self._get_nested_value(payload, target_path)
if value is not None:
field_type = mapping.get("type", "str")
# Handle enum reverse mapping
if field_type == "enum" and "values" in mapping and isinstance(mapping["values"], dict):
# Find key by value
for key, display_value in mapping["values"].items():
if display_value == value or key == value:
value = key
break
# Format value for PDF
if field_type == "float":
value = self._format_amount(value)
elif field_type == "bool":
value = "Ja" if value else "Nein"
pdf_fields[field_name] = str(value) if value is not None else ""
# Process costs array
pa = payload.get("pa", {})
project = pa.get("project", {})
costs = project.get("costs", [])
for i, cost in enumerate(costs[:24], 1): # Limit to 24 cost positions
if "name" in cost:
pdf_fields[f"pa-cost-{i}-name"] = cost["name"]
if "amountEur" in cost:
pdf_fields[f"pa-cost-{i}-amount-euro"] = self._format_amount(cost["amountEur"])
# Process participation faculties
participation = project.get("participation", {})
faculties = participation.get("faculties", {})
for faculty, is_participating in faculties.items():
pdf_fields[f"pa-participating-faculties-{faculty}"] = "Ja" if is_participating else "Nein"
# Process QSM flags
financing = project.get("financing", {})
qsm = financing.get("qsm", {})
flags = qsm.get("flags", {})
for flag_key, flag_value in flags.items():
pdf_fields[f"pa-qsm-{flag_key}"] = "Ja" if flag_value else "Nein"
return pdf_fields
def validate_payload(self, payload: Dict[str, Any]) -> List[str]:
"""Validate a payload for this variant"""
errors = []
# Check required fields
for field_name, mapping in self._field_mapping.items():
if mapping.get("required", False):
target_path = mapping["target"]
value = self._get_nested_value(payload, target_path)
if value is None or (isinstance(value, str) and not value.strip()):
errors.append(f"Required field missing: {target_path}")
# Validate QSM-specific requirements
pa = payload.get("pa", {})
project = pa.get("project", {})
# Check if financing code is valid
financing = project.get("financing", {})
qsm = financing.get("qsm", {})
financing_code = qsm.get("code")
if not financing_code:
errors.append("QSM financing code is required")
elif financing_code not in self._field_mapping["pa-qsm-financing"]["values"]:
errors.append(f"Invalid QSM financing code: {financing_code}")
# Validate costs
costs = project.get("costs", [])
if not costs:
errors.append("At least one cost position is required")
else:
for i, cost in enumerate(costs):
if not cost.get("name"):
errors.append(f"Cost position {i+1}: name is required")
if cost.get("amountEur") is not None:
try:
amount = float(cost["amountEur"])
if amount < 0:
errors.append(f"Cost position {i+1}: amount cannot be negative")
except (TypeError, ValueError):
errors.append(f"Cost position {i+1}: invalid amount")
return errors
def detect_variant(self, pdf_fields: Dict[str, Any]) -> bool:
"""Check if the given PDF fields match this variant"""
# Check for QSM-specific fields
qsm_indicators = ["pa-qsm-financing"]
for indicator in qsm_indicators:
if indicator in pdf_fields and pdf_fields[indicator]:
return True
# Check for QSM flags
for field_name in pdf_fields:
if field_name.startswith("pa-qsm-vwv-"):
return True
return False
# Helper methods
def _set_nested_value(self, obj: Dict[str, Any], path: str, value: Any):
"""Set a value in a nested dictionary using dot notation"""
keys = path.split(".")
current = obj
for key in keys[:-1]:
if key not in current:
current[key] = {}
current = current[key]
current[keys[-1]] = value
def _get_nested_value(self, obj: Dict[str, Any], path: str) -> Any:
"""Get a value from a nested dictionary using dot notation"""
keys = path.split(".")
current = obj
for key in keys:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return None
return current
def _parse_amount(self, value: str) -> float:
"""Parse amount from German format (1.234,56) to float"""
if not value:
return 0.0
# Remove thousands separator and replace comma with dot
cleaned = value.replace(".", "").replace(",", ".")
try:
return float(cleaned)
except (TypeError, ValueError):
return 0.0
def _format_amount(self, value: float) -> str:
"""Format amount to German format (1.234,56)"""
return f"{value:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
def _parse_bool(self, value: Any) -> bool:
"""Parse boolean value from various formats"""
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in ["true", "yes", "1", "on", "ja", "/Yes"]
return bool(value)

View File

@ -0,0 +1,457 @@
"""
Application Repository
This module provides the repository for application database operations.
"""
from typing import Optional, List, Dict, Any, Tuple
from datetime import datetime, timedelta
from sqlalchemy import and_, or_, func, desc, asc
from sqlalchemy.orm import Session, joinedload
from .base import BaseRepository, RepositoryException
from ..models.application import (
Application,
ApplicationStatus,
ApplicationType,
InstitutionType,
ApplicationAttachment,
ComparisonOffer,
CostPositionJustification,
Counter
)
class ApplicationRepository(BaseRepository[Application]):
"""Repository for application database operations"""
def __init__(self, session: Session):
"""Initialize application repository"""
super().__init__(session, Application)
def get_by_pa_id(self, pa_id: str) -> Optional[Application]:
"""
Get application by PA ID.
Args:
pa_id: Public application ID
Returns:
Application or None if not found
"""
return self.get_by(pa_id=pa_id)
def find_by_status(
self,
status: ApplicationStatus,
limit: Optional[int] = None,
offset: Optional[int] = None
) -> List[Application]:
"""
Find applications by status.
Args:
status: Application status
limit: Maximum number of results
offset: Number of results to skip
Returns:
List of applications
"""
query = self.query().filter(Application.status == status)
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
return query.all()
def find_by_institution(
self,
institution_type: Optional[InstitutionType] = None,
institution_name: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None
) -> List[Application]:
"""
Find applications by institution.
Args:
institution_type: Type of institution
institution_name: Name of institution
limit: Maximum number of results
offset: Number of results to skip
Returns:
List of applications
"""
query = self.query()
if institution_type:
query = query.filter(Application.institution_type == institution_type)
if institution_name:
query = query.filter(Application.institution_name.ilike(f"%{institution_name}%"))
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
return query.all()
def find_by_applicant(
self,
email: Optional[str] = None,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None
) -> List[Application]:
"""
Find applications by applicant information.
Args:
email: Applicant email
first_name: Applicant first name
last_name: Applicant last name
limit: Maximum number of results
offset: Number of results to skip
Returns:
List of applications
"""
query = self.query()
if email:
query = query.filter(Application.applicant_email.ilike(f"%{email}%"))
if first_name:
query = query.filter(Application.applicant_first_name.ilike(f"%{first_name}%"))
if last_name:
query = query.filter(Application.applicant_last_name.ilike(f"%{last_name}%"))
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
return query.all()
def find_by_date_range(
self,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
date_field: str = "created_at",
limit: Optional[int] = None,
offset: Optional[int] = None
) -> List[Application]:
"""
Find applications within a date range.
Args:
start_date: Start date (ISO format)
end_date: End date (ISO format)
date_field: Field to filter by (created_at, submitted_at, etc.)
limit: Maximum number of results
offset: Number of results to skip
Returns:
List of applications
"""
query = self.query()
if hasattr(Application, date_field):
field = getattr(Application, date_field)
if start_date:
query = query.filter(field >= start_date)
if end_date:
query = query.filter(field <= end_date)
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
return query.all()
def search(
self,
search_query: str,
filters: Optional[Dict[str, Any]] = None,
order_by: str = "created_at",
order_desc: bool = True,
limit: int = 20,
offset: int = 0
) -> Tuple[List[Application], int]:
"""
Search applications with full-text search and filters.
Args:
search_query: Search query string
filters: Additional filters
order_by: Field to order by
order_desc: Whether to order descending
limit: Maximum number of results
offset: Number of results to skip
Returns:
Tuple of (applications, total_count)
"""
query = self.query()
# Apply search query
if search_query:
search_term = f"%{search_query}%"
query = query.filter(
or_(
Application.pa_id.ilike(search_term),
Application.project_name.ilike(search_term),
Application.institution_name.ilike(search_term),
Application.applicant_email.ilike(search_term),
Application.applicant_first_name.ilike(search_term),
Application.applicant_last_name.ilike(search_term)
)
)
# Apply filters
if filters:
if "status" in filters:
query = query.filter(Application.status == filters["status"])
if "variant" in filters:
query = query.filter(Application.variant == filters["variant"])
if "institution_type" in filters:
query = query.filter(Application.institution_type == filters["institution_type"])
if "min_amount" in filters:
query = query.filter(Application.total_amount >= filters["min_amount"])
if "max_amount" in filters:
query = query.filter(Application.total_amount <= filters["max_amount"])
if "created_after" in filters:
query = query.filter(Application.created_at >= filters["created_after"])
if "created_before" in filters:
query = query.filter(Application.created_at <= filters["created_before"])
if "is_deleted" in filters:
query = query.filter(Application.is_deleted == filters["is_deleted"])
# Get total count
total_count = query.count()
# Apply ordering
if hasattr(Application, order_by):
field = getattr(Application, order_by)
query = query.order_by(desc(field) if order_desc else asc(field))
# Apply pagination
query = query.offset(offset).limit(limit)
return query.all(), total_count
def get_with_attachments(self, id: int) -> Optional[Application]:
"""
Get application with attachments loaded.
Args:
id: Application ID
Returns:
Application with attachments or None
"""
return self.query().options(
joinedload(Application.attachments).joinedload(ApplicationAttachment.attachment)
).filter(Application.id == id).first()
def get_with_offers(self, id: int) -> Optional[Application]:
"""
Get application with comparison offers loaded.
Args:
id: Application ID
Returns:
Application with offers or None
"""
return self.query().options(
joinedload(Application.comparison_offers).joinedload(ComparisonOffer.attachment)
).filter(Application.id == id).first()
def get_statistics(
self,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Get application statistics.
Args:
start_date: Start date for statistics
end_date: End date for statistics
Returns:
Dictionary with statistics
"""
query = self.session.query(Application)
if start_date:
query = query.filter(Application.created_at >= start_date)
if end_date:
query = query.filter(Application.created_at <= end_date)
# Count by status
status_counts = {}
for status in ApplicationStatus:
count = query.filter(Application.status == status).count()
status_counts[status.value] = count
# Count by variant
variant_counts = {}
for variant in ApplicationType:
count = query.filter(Application.variant == variant).count()
variant_counts[variant.value] = count
# Count by institution type
institution_counts = {}
for inst_type in InstitutionType:
count = query.filter(Application.institution_type == inst_type).count()
institution_counts[inst_type.value] = count
# Calculate totals
total_applications = query.count()
total_amount = self.session.query(
func.sum(Application.total_amount)
).scalar() or 0.0
# Average processing time
completed_apps = query.filter(
Application.status == ApplicationStatus.COMPLETED,
Application.completed_at.isnot(None)
).all()
avg_processing_time = None
if completed_apps:
processing_times = [
(app.completed_at - app.created_at).total_seconds() / 86400
for app in completed_apps
if app.completed_at
]
if processing_times:
avg_processing_time = sum(processing_times) / len(processing_times)
return {
"total_applications": total_applications,
"total_amount": float(total_amount),
"status_distribution": status_counts,
"variant_distribution": variant_counts,
"institution_distribution": institution_counts,
"average_processing_days": avg_processing_time,
"date_range": {
"start": start_date.isoformat() if start_date else None,
"end": end_date.isoformat() if end_date else None
}
}
def bulk_update_status(
self,
application_ids: List[int],
new_status: ApplicationStatus,
user: Optional[str] = None
) -> int:
"""
Bulk update application status.
Args:
application_ids: List of application IDs
new_status: New status to set
user: User performing the update
Returns:
Number of updated applications
"""
try:
now = datetime.utcnow()
update_data = {
"status": new_status,
"updated_at": now
}
if user:
update_data["updated_by"] = user
if new_status == ApplicationStatus.SUBMITTED:
update_data["submitted_at"] = now
elif new_status in [ApplicationStatus.APPROVED, ApplicationStatus.REJECTED]:
update_data["reviewed_at"] = now
update_data["reviewed_by"] = user
elif new_status == ApplicationStatus.COMPLETED:
update_data["completed_at"] = now
count = self.session.query(Application).filter(
Application.id.in_(application_ids)
).update(update_data, synchronize_session=False)
self.session.commit()
return count
except Exception as e:
self.session.rollback()
raise RepositoryException(f"Failed to bulk update status: {str(e)}")
def generate_next_pa_id(self, prefix: str = "PA") -> str:
"""
Generate next sequential PA ID.
Args:
prefix: Prefix for the ID
Returns:
Generated PA ID
"""
try:
# Get or create counter
counter = self.session.query(Counter).filter_by(
key="application_id"
).with_for_update().first()
if not counter:
counter = Counter(
key="application_id",
value=0,
prefix=prefix,
suffix="",
format_string="{prefix}{value:06d}"
)
self.session.add(counter)
# Increment counter
counter.value += 1
pa_id = counter.format_id()
self.session.flush()
return pa_id
except Exception as e:
self.session.rollback()
raise RepositoryException(f"Failed to generate PA ID: {str(e)}")
def cleanup_old_drafts(self, days: int = 30) -> int:
"""
Clean up old draft applications.
Args:
days: Number of days to keep drafts
Returns:
Number of deleted applications
"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days)
count = self.session.query(Application).filter(
Application.status == ApplicationStatus.DRAFT,
Application.created_at < cutoff_date
).delete(synchronize_session=False)
self.session.commit()
return count
except Exception as e:
self.session.rollback()
raise RepositoryException(f"Failed to cleanup old drafts: {str(e)}")

View File

@ -0,0 +1,466 @@
"""
Base Repository Pattern
This module provides base repository classes for database operations.
All repositories should inherit from these base classes.
"""
from typing import Generic, TypeVar, Type, Optional, List, Dict, Any, Union
from sqlalchemy.orm import Session, Query
from sqlalchemy import and_, or_, desc, asc, func
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from contextlib import contextmanager
import logging
from ..models.base import BaseModel
logger = logging.getLogger(__name__)
T = TypeVar('T', bound=BaseModel)
class RepositoryException(Exception):
"""Base exception for repository errors"""
pass
class NotFoundError(RepositoryException):
"""Raised when an entity is not found"""
pass
class DuplicateError(RepositoryException):
"""Raised when trying to create a duplicate entity"""
pass
class BaseRepository(Generic[T]):
"""
Base repository class providing common CRUD operations.
This class implements the repository pattern for database access,
providing a clean abstraction over SQLAlchemy operations.
"""
def __init__(self, session: Session, model_class: Type[T]):
"""
Initialize repository.
Args:
session: SQLAlchemy session
model_class: Model class this repository manages
"""
self.session = session
self.model_class = model_class
@contextmanager
def transaction(self):
"""
Context manager for handling transactions.
Usage:
with repository.transaction():
repository.create(entity)
repository.update(another_entity)
"""
try:
yield self.session
self.session.commit()
except SQLAlchemyError as e:
self.session.rollback()
logger.error(f"Transaction failed: {e}")
raise RepositoryException(f"Transaction failed: {str(e)}")
def query(self) -> Query:
"""Get base query for the model"""
return self.session.query(self.model_class)
def get(self, id: Union[int, str]) -> Optional[T]:
"""
Get entity by ID.
Args:
id: Entity ID
Returns:
Entity instance or None if not found
"""
try:
return self.query().filter(self.model_class.id == id).first()
except SQLAlchemyError as e:
logger.error(f"Error getting entity {id}: {e}")
raise RepositoryException(f"Failed to get entity: {str(e)}")
def get_or_404(self, id: Union[int, str]) -> T:
"""
Get entity by ID or raise NotFoundError.
Args:
id: Entity ID
Returns:
Entity instance
Raises:
NotFoundError: If entity not found
"""
entity = self.get(id)
if entity is None:
raise NotFoundError(f"{self.model_class.__name__} with id {id} not found")
return entity
def get_by(self, **kwargs) -> Optional[T]:
"""
Get single entity by field values.
Args:
**kwargs: Field names and values to filter by
Returns:
Entity instance or None if not found
"""
try:
query = self.query()
for key, value in kwargs.items():
if hasattr(self.model_class, key):
query = query.filter(getattr(self.model_class, key) == value)
return query.first()
except SQLAlchemyError as e:
logger.error(f"Error getting entity by {kwargs}: {e}")
raise RepositoryException(f"Failed to get entity: {str(e)}")
def find(self, **kwargs) -> List[T]:
"""
Find entities by field values.
Args:
**kwargs: Field names and values to filter by
Returns:
List of matching entities
"""
try:
query = self.query()
for key, value in kwargs.items():
if hasattr(self.model_class, key):
if value is None:
query = query.filter(getattr(self.model_class, key).is_(None))
else:
query = query.filter(getattr(self.model_class, key) == value)
return query.all()
except SQLAlchemyError as e:
logger.error(f"Error finding entities by {kwargs}: {e}")
raise RepositoryException(f"Failed to find entities: {str(e)}")
def find_all(
self,
filters: Optional[Dict[str, Any]] = None,
order_by: Optional[str] = None,
order_desc: bool = False,
limit: Optional[int] = None,
offset: Optional[int] = None
) -> List[T]:
"""
Find all entities with optional filtering and pagination.
Args:
filters: Dictionary of field names and values to filter by
order_by: Field name to order by
order_desc: Whether to order descending
limit: Maximum number of results
offset: Number of results to skip
Returns:
List of entities
"""
try:
query = self.query()
# Apply filters
if filters:
for key, value in filters.items():
if hasattr(self.model_class, key):
field = getattr(self.model_class, key)
if value is None:
query = query.filter(field.is_(None))
elif isinstance(value, list):
query = query.filter(field.in_(value))
elif isinstance(value, dict):
# Support operators like {'gte': 100, 'lt': 200}
for op, val in value.items():
if op == 'gte':
query = query.filter(field >= val)
elif op == 'gt':
query = query.filter(field > val)
elif op == 'lte':
query = query.filter(field <= val)
elif op == 'lt':
query = query.filter(field < val)
elif op == 'ne':
query = query.filter(field != val)
elif op == 'like':
query = query.filter(field.like(f"%{val}%"))
elif op == 'ilike':
query = query.filter(field.ilike(f"%{val}%"))
else:
query = query.filter(field == value)
# Apply ordering
if order_by and hasattr(self.model_class, order_by):
field = getattr(self.model_class, order_by)
query = query.order_by(desc(field) if order_desc else asc(field))
# Apply pagination
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
return query.all()
except SQLAlchemyError as e:
logger.error(f"Error finding all entities: {e}")
raise RepositoryException(f"Failed to find entities: {str(e)}")
def count(self, **kwargs) -> int:
"""
Count entities matching criteria.
Args:
**kwargs: Field names and values to filter by
Returns:
Number of matching entities
"""
try:
query = self.session.query(func.count(self.model_class.id))
for key, value in kwargs.items():
if hasattr(self.model_class, key):
query = query.filter(getattr(self.model_class, key) == value)
return query.scalar()
except SQLAlchemyError as e:
logger.error(f"Error counting entities: {e}")
raise RepositoryException(f"Failed to count entities: {str(e)}")
def exists(self, **kwargs) -> bool:
"""
Check if entity exists.
Args:
**kwargs: Field names and values to filter by
Returns:
True if entity exists, False otherwise
"""
return self.count(**kwargs) > 0
def create(self, entity: T, commit: bool = True) -> T:
"""
Create new entity.
Args:
entity: Entity instance to create
commit: Whether to commit immediately
Returns:
Created entity
Raises:
DuplicateError: If entity violates unique constraint
"""
try:
self.session.add(entity)
if commit:
self.session.commit()
self.session.refresh(entity)
else:
self.session.flush()
return entity
except IntegrityError as e:
self.session.rollback()
logger.error(f"Integrity error creating entity: {e}")
raise DuplicateError(f"Entity already exists or violates constraint: {str(e)}")
except SQLAlchemyError as e:
self.session.rollback()
logger.error(f"Error creating entity: {e}")
raise RepositoryException(f"Failed to create entity: {str(e)}")
def create_many(self, entities: List[T], commit: bool = True) -> List[T]:
"""
Create multiple entities.
Args:
entities: List of entity instances to create
commit: Whether to commit immediately
Returns:
List of created entities
"""
try:
self.session.add_all(entities)
if commit:
self.session.commit()
for entity in entities:
self.session.refresh(entity)
else:
self.session.flush()
return entities
except IntegrityError as e:
self.session.rollback()
logger.error(f"Integrity error creating entities: {e}")
raise DuplicateError(f"One or more entities already exist: {str(e)}")
except SQLAlchemyError as e:
self.session.rollback()
logger.error(f"Error creating entities: {e}")
raise RepositoryException(f"Failed to create entities: {str(e)}")
def update(self, entity: T, commit: bool = True) -> T:
"""
Update entity.
Args:
entity: Entity instance with updated values
commit: Whether to commit immediately
Returns:
Updated entity
"""
try:
if commit:
self.session.commit()
self.session.refresh(entity)
else:
self.session.flush()
return entity
except SQLAlchemyError as e:
self.session.rollback()
logger.error(f"Error updating entity: {e}")
raise RepositoryException(f"Failed to update entity: {str(e)}")
def update_by_id(self, id: Union[int, str], data: Dict[str, Any], commit: bool = True) -> Optional[T]:
"""
Update entity by ID.
Args:
id: Entity ID
data: Dictionary of fields to update
commit: Whether to commit immediately
Returns:
Updated entity or None if not found
"""
entity = self.get(id)
if entity:
for key, value in data.items():
if hasattr(entity, key):
setattr(entity, key, value)
return self.update(entity, commit)
return None
def delete(self, entity: T, commit: bool = True) -> bool:
"""
Delete entity.
Args:
entity: Entity instance to delete
commit: Whether to commit immediately
Returns:
True if deleted successfully
"""
try:
self.session.delete(entity)
if commit:
self.session.commit()
else:
self.session.flush()
return True
except SQLAlchemyError as e:
self.session.rollback()
logger.error(f"Error deleting entity: {e}")
raise RepositoryException(f"Failed to delete entity: {str(e)}")
def delete_by_id(self, id: Union[int, str], commit: bool = True) -> bool:
"""
Delete entity by ID.
Args:
id: Entity ID
commit: Whether to commit immediately
Returns:
True if deleted, False if not found
"""
entity = self.get(id)
if entity:
return self.delete(entity, commit)
return False
def delete_many(self, filters: Dict[str, Any], commit: bool = True) -> int:
"""
Delete multiple entities matching criteria.
Args:
filters: Dictionary of field names and values to filter by
commit: Whether to commit immediately
Returns:
Number of deleted entities
"""
try:
query = self.query()
for key, value in filters.items():
if hasattr(self.model_class, key):
query = query.filter(getattr(self.model_class, key) == value)
count = query.count()
query.delete(synchronize_session=False)
if commit:
self.session.commit()
else:
self.session.flush()
return count
except SQLAlchemyError as e:
self.session.rollback()
logger.error(f"Error deleting entities: {e}")
raise RepositoryException(f"Failed to delete entities: {str(e)}")
def refresh(self, entity: T) -> T:
"""
Refresh entity from database.
Args:
entity: Entity instance to refresh
Returns:
Refreshed entity
"""
try:
self.session.refresh(entity)
return entity
except SQLAlchemyError as e:
logger.error(f"Error refreshing entity: {e}")
raise RepositoryException(f"Failed to refresh entity: {str(e)}")
def commit(self):
"""Commit current transaction"""
try:
self.session.commit()
except SQLAlchemyError as e:
self.session.rollback()
logger.error(f"Error committing transaction: {e}")
raise RepositoryException(f"Failed to commit transaction: {str(e)}")
def rollback(self):
"""Rollback current transaction"""
self.session.rollback()
def flush(self):
"""Flush pending changes without committing"""
try:
self.session.flush()
except SQLAlchemyError as e:
self.session.rollback()
logger.error(f"Error flushing changes: {e}")
raise RepositoryException(f"Failed to flush changes: {str(e)}")

View File

@ -0,0 +1,563 @@
"""
Application Service
This module provides the business logic for application management.
"""
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime
import hashlib
import secrets
import base64
import json
from .base import CRUDService, ValidationError, BusinessRuleViolation, ResourceNotFoundError
from ..repositories.application import ApplicationRepository
from ..models.application import (
Application,
ApplicationStatus,
ApplicationType,
InstitutionType
)
from ..config.settings import Settings
class ApplicationService(CRUDService[Application]):
"""Service for application business logic"""
def __init__(
self,
repository: ApplicationRepository,
pdf_service: Optional['PDFService'] = None,
settings: Optional[Settings] = None
):
"""
Initialize application service.
Args:
repository: Application repository
pdf_service: PDF processing service
settings: Application settings
"""
super().__init__(repository, settings)
self.repository: ApplicationRepository = repository
self.pdf_service = pdf_service
def validate_create(self, data: Dict[str, Any]):
"""Validate data for application creation"""
# Required fields
self.validate_required_fields(data, ["variant", "payload"])
# Validate variant
variant = data.get("variant", "").upper()
if variant not in ["QSM", "VSM"]:
raise ValidationError("Invalid variant", {"variant": "Must be QSM or VSM"})
# Validate payload structure
payload = data.get("payload", {})
if not isinstance(payload, dict):
raise ValidationError("Invalid payload", {"payload": "Must be a dictionary"})
# Validate payload content
self._validate_payload(payload, variant)
def validate_update(self, data: Dict[str, Any], entity: Application, partial: bool = True):
"""Validate data for application update"""
# Check if application can be updated
if entity.status in [ApplicationStatus.APPROVED, ApplicationStatus.COMPLETED]:
raise BusinessRuleViolation(
f"Cannot update application in {entity.status.value} status"
)
# If updating payload, validate it
if "payload" in data:
payload = data["payload"]
if not isinstance(payload, dict):
raise ValidationError("Invalid payload", {"payload": "Must be a dictionary"})
variant = data.get("variant", entity.variant.value)
self._validate_payload(payload, variant, partial=partial)
# If updating status, validate transition
if "status" in data:
new_status = data["status"]
if isinstance(new_status, str):
try:
new_status = ApplicationStatus(new_status)
except ValueError:
raise ValidationError(
"Invalid status",
{"status": f"Invalid status value: {new_status}"}
)
self._validate_status_transition(entity.status, new_status)
def validate_delete(self, entity: Application):
"""Validate application deletion"""
if entity.status in [ApplicationStatus.APPROVED, ApplicationStatus.IN_REVIEW]:
raise BusinessRuleViolation(
f"Cannot delete application in {entity.status.value} status"
)
def _validate_payload(self, payload: Dict[str, Any], variant: str, partial: bool = False):
"""
Validate application payload.
Args:
payload: Application payload
variant: Application variant (QSM or VSM)
partial: Whether this is a partial update
"""
errors = {}
# Check for required top-level structure
if not partial and "pa" not in payload:
errors["pa"] = "Application data is required"
if "pa" in payload:
pa = payload["pa"]
# Validate applicant information
if not partial and "applicant" not in pa:
errors["applicant"] = "Applicant information is required"
if "applicant" in pa:
applicant = pa["applicant"]
# Validate required applicant fields
if not partial:
required_applicant_fields = ["name", "contact", "institution"]
for field in required_applicant_fields:
if field not in applicant:
errors[f"applicant.{field}"] = f"{field} is required"
# Validate email format
if "contact" in applicant and "email" in applicant["contact"]:
email = applicant["contact"]["email"]
if email and "@" not in email:
errors["applicant.contact.email"] = "Invalid email format"
# Validate institution type
if "institution" in applicant and "type" in applicant["institution"]:
inst_type = applicant["institution"]["type"]
if inst_type and inst_type != "-":
valid_types = [e.value for e in InstitutionType]
if inst_type not in valid_types:
errors["applicant.institution.type"] = f"Invalid institution type: {inst_type}"
# Validate project information
if not partial and "project" not in pa:
errors["project"] = "Project information is required"
if "project" in pa:
project = pa["project"]
# Validate required project fields
if not partial:
required_project_fields = ["name", "description", "dates"]
for field in required_project_fields:
if field not in project:
errors[f"project.{field}"] = f"{field} is required"
# Validate dates
if "dates" in project:
dates = project["dates"]
if "start" in dates and dates["start"]:
# Validate date format (basic check)
if not self._is_valid_date_format(dates["start"]):
errors["project.dates.start"] = "Invalid date format"
if "end" in dates and dates["end"]:
if not self._is_valid_date_format(dates["end"]):
errors["project.dates.end"] = "Invalid date format"
# Validate costs
if "costs" in project:
costs = project["costs"]
if not isinstance(costs, list):
errors["project.costs"] = "Costs must be a list"
else:
for i, cost in enumerate(costs):
if not isinstance(cost, dict):
errors[f"project.costs[{i}]"] = "Cost must be a dictionary"
elif "amountEur" in cost:
try:
float(cost["amountEur"])
except (TypeError, ValueError):
errors[f"project.costs[{i}].amountEur"] = "Amount must be a number"
if errors:
raise ValidationError("Payload validation failed", errors)
def _validate_status_transition(self, current_status: ApplicationStatus, new_status: ApplicationStatus):
"""
Validate status transition.
Args:
current_status: Current application status
new_status: New application status
Raises:
BusinessRuleViolation: If transition is not allowed
"""
allowed_transitions = {
ApplicationStatus.DRAFT: [
ApplicationStatus.SUBMITTED,
ApplicationStatus.CANCELLED
],
ApplicationStatus.SUBMITTED: [
ApplicationStatus.IN_REVIEW,
ApplicationStatus.CANCELLED
],
ApplicationStatus.IN_REVIEW: [
ApplicationStatus.APPROVED,
ApplicationStatus.REJECTED,
ApplicationStatus.SUBMITTED # Send back for revision
],
ApplicationStatus.APPROVED: [
ApplicationStatus.COMPLETED,
ApplicationStatus.CANCELLED
],
ApplicationStatus.REJECTED: [], # Terminal state
ApplicationStatus.CANCELLED: [], # Terminal state
ApplicationStatus.COMPLETED: [] # Terminal state
}
if new_status not in allowed_transitions.get(current_status, []):
raise BusinessRuleViolation(
f"Cannot transition from {current_status.value} to {new_status.value}"
)
def _is_valid_date_format(self, date_str: str) -> bool:
"""Check if date string has valid format (YYYY-MM-DD or YYYY-MM)"""
if not date_str:
return False
# Basic format check
parts = date_str.split("-")
if len(parts) not in [2, 3]:
return False
try:
year = int(parts[0])
month = int(parts[1])
if year < 1900 or year > 2100:
return False
if month < 1 or month > 12:
return False
if len(parts) == 3:
day = int(parts[2])
if day < 1 or day > 31:
return False
return True
except (ValueError, IndexError):
return False
def _create_entity_from_data(self, data: Dict[str, Any]) -> Application:
"""Create application entity from data"""
# Generate PA ID if not provided
if "pa_id" not in data:
data["pa_id"] = self.repository.generate_next_pa_id()
# Generate access key
if "pa_key" not in data:
raw_key = secrets.token_urlsafe(32)
data["pa_key_hash"] = self._hash_key(raw_key)
data["pa_key_raw"] = raw_key # Store temporarily for response
else:
# Hash provided key
data["pa_key_hash"] = self._hash_key(data["pa_key"])
data["pa_key_raw"] = data["pa_key"]
# Create application instance
app = Application(
pa_id=data["pa_id"],
pa_key=data["pa_key_hash"],
variant=ApplicationType(data["variant"].upper()),
status=ApplicationStatus(data.get("status", ApplicationStatus.DRAFT.value)),
payload=data["payload"]
)
# Update searchable fields from payload
app.update_from_payload()
# Set timestamps based on status
if app.status == ApplicationStatus.SUBMITTED:
app.submitted_at = datetime.utcnow()
return app
def _update_entity_from_data(self, entity: Application, data: Dict[str, Any]):
"""Update application entity from data"""
# Update basic fields
if "variant" in data:
entity.variant = ApplicationType(data["variant"].upper())
if "payload" in data:
entity.payload = data["payload"]
entity.update_from_payload()
if "status" in data:
new_status = ApplicationStatus(data["status"])
entity.status = new_status
# Update timestamps based on status
now = datetime.utcnow()
if new_status == ApplicationStatus.SUBMITTED and not entity.submitted_at:
entity.submitted_at = now
elif new_status in [ApplicationStatus.APPROVED, ApplicationStatus.REJECTED]:
entity.reviewed_at = now
if "reviewed_by" in data:
entity.reviewed_by = data["reviewed_by"]
elif new_status == ApplicationStatus.COMPLETED:
entity.completed_at = now
def _hash_key(self, key: str) -> str:
"""Hash an access key"""
salt = self.settings.security.master_key or "default_salt"
return hashlib.sha256(f"{salt}:{key}".encode()).hexdigest()
def verify_key(self, pa_id: str, key: str) -> bool:
"""
Verify access key for an application.
Args:
pa_id: Public application ID
key: Access key to verify
Returns:
True if key is valid, False otherwise
"""
app = self.repository.get_by_pa_id(pa_id)
if not app:
return False
hashed_key = self._hash_key(key)
return app.pa_key == hashed_key
def create_from_pdf(
self,
pdf_data: bytes,
variant: Optional[str] = None,
user: Optional[str] = None
) -> Application:
"""
Create application from PDF data.
Args:
pdf_data: PDF file data
variant: PDF variant (QSM or VSM)
user: User creating the application
Returns:
Created application
Raises:
ValidationError: If PDF parsing fails
ServiceException: If creation fails
"""
if not self.pdf_service:
raise BusinessRuleViolation("PDF service not available")
# Parse PDF to payload
try:
payload = self.pdf_service.parse_pdf(pdf_data, variant)
except Exception as e:
raise ValidationError(f"Failed to parse PDF: {str(e)}")
# Detect variant if not provided
if not variant:
variant = self.pdf_service.detect_variant(payload)
# Create application data
data = {
"variant": variant,
"payload": payload,
"status": ApplicationStatus.DRAFT.value
}
# Create application
return self.create(data, user=user)
def generate_pdf(self, id: int, flatten: bool = True) -> bytes:
"""
Generate PDF for an application.
Args:
id: Application ID
flatten: Whether to flatten the PDF
Returns:
PDF data
Raises:
ResourceNotFoundError: If application not found
ServiceException: If PDF generation fails
"""
if not self.pdf_service:
raise BusinessRuleViolation("PDF service not available")
# Get application
app = self.get_or_404(id)
# Generate PDF
try:
pdf_data = self.pdf_service.fill_pdf(
payload=app.payload,
variant=app.variant.value,
flatten=flatten
)
# Store PDF data
app.pdf_data = base64.b64encode(pdf_data).decode('utf-8')
app.pdf_generated_at = datetime.utcnow()
self.repository.update(app)
return pdf_data
except Exception as e:
self.log_error(f"Failed to generate PDF for application {id}", e)
raise ServiceException(f"Failed to generate PDF: {str(e)}")
def submit_application(self, id: int, user: Optional[str] = None) -> Application:
"""
Submit an application for review.
Args:
id: Application ID
user: User submitting the application
Returns:
Updated application
Raises:
BusinessRuleViolation: If application cannot be submitted
"""
app = self.get_or_404(id)
# Validate status transition
if app.status != ApplicationStatus.DRAFT:
raise BusinessRuleViolation(
f"Can only submit applications in DRAFT status, current status: {app.status.value}"
)
# Validate payload completeness
self._validate_payload(app.payload, app.variant.value, partial=False)
# Update status
update_data = {
"status": ApplicationStatus.SUBMITTED.value
}
return self.update(id, update_data, user=user)
def review_application(
self,
id: int,
approved: bool,
comments: Optional[str] = None,
reviewer: Optional[str] = None
) -> Application:
"""
Review an application.
Args:
id: Application ID
approved: Whether to approve the application
comments: Review comments
reviewer: Reviewer identifier
Returns:
Updated application
Raises:
BusinessRuleViolation: If application cannot be reviewed
"""
app = self.get_or_404(id)
# Validate status
if app.status not in [ApplicationStatus.SUBMITTED, ApplicationStatus.IN_REVIEW]:
raise BusinessRuleViolation(
f"Can only review applications in SUBMITTED or IN_REVIEW status"
)
# Update status
new_status = ApplicationStatus.APPROVED if approved else ApplicationStatus.REJECTED
update_data = {
"status": new_status.value,
"reviewed_by": reviewer
}
# Add comments to payload if provided
if comments:
payload = app.payload.copy()
if "review" not in payload:
payload["review"] = {}
payload["review"]["comments"] = comments
payload["review"]["decision"] = "approved" if approved else "rejected"
payload["review"]["reviewer"] = reviewer
payload["review"]["date"] = datetime.utcnow().isoformat()
update_data["payload"] = payload
return self.update(id, update_data, user=reviewer)
def get_statistics(
self,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Get application statistics.
Args:
start_date: Start date for statistics
end_date: End date for statistics
Returns:
Statistics dictionary
"""
return self.repository.get_statistics(start_date, end_date)
def bulk_update_status(
self,
application_ids: List[int],
new_status: ApplicationStatus,
user: Optional[str] = None
) -> int:
"""
Bulk update application status.
Args:
application_ids: List of application IDs
new_status: New status to set
user: User performing the update
Returns:
Number of updated applications
"""
# Validate each application can transition to new status
apps = [self.get(app_id) for app_id in application_ids]
for app in apps:
if app:
try:
self._validate_status_transition(app.status, new_status)
except BusinessRuleViolation as e:
self.log_warning(f"Cannot update application {app.id}: {str(e)}")
application_ids.remove(app.id)
# Perform bulk update
return self.repository.bulk_update_status(application_ids, new_status, user)
def cleanup_old_drafts(self, days: int = 30) -> int:
"""
Clean up old draft applications.
Args:
days: Number of days to keep drafts
Returns:
Number of deleted applications
"""
count = self.repository.cleanup_old_drafts(days)
self.log_info(f"Cleaned up {count} old draft applications older than {days} days")
return count

View File

@ -0,0 +1,380 @@
"""
Email Authentication and Verification Service
This module provides email-based authentication and verification.
"""
import secrets
import hashlib
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, Tuple
import logging
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from jose import JWTError, jwt as jose_jwt
from ..models.user import User, Role, AuthProvider, VerificationStatus
from ..config.settings import Settings
from ..repositories.user import UserRepository
from ..repositories.role import RoleRepository
from ..utils.email import EmailService
logger = logging.getLogger(__name__)
class EmailAuthService:
"""Service for handling email-based authentication"""
def __init__(self, db_session: Session, settings: Settings):
self.db = db_session
self.settings = settings
self.user_repo = UserRepository(db_session)
self.role_repo = RoleRepository(db_session)
self.email_service = EmailService(settings)
def generate_verification_token(self) -> str:
"""Generate a secure verification token"""
return secrets.token_urlsafe(32)
def hash_token(self, token: str) -> str:
"""Hash a token for storage"""
return hashlib.sha256(token.encode()).hexdigest()
async def register_user(self, email: str, name: Optional[str] = None) -> User:
"""Register a new user with email verification"""
# Check if user already exists
existing_user = self.user_repo.get_by_email(email)
if existing_user:
if existing_user.auth_provider != AuthProvider.EMAIL:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already exists with different authentication method"
)
if existing_user.email_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already verified"
)
# Resend verification email
return await self.send_verification_email(existing_user)
# Parse name if provided
given_name = None
family_name = None
if name:
name_parts = name.split(" ", 1)
given_name = name_parts[0]
family_name = name_parts[1] if len(name_parts) > 1 else None
# Create new user
user = User(
email=email,
auth_provider=AuthProvider.EMAIL,
given_name=given_name,
family_name=family_name,
display_name=name,
email_verified=False,
verification_status=VerificationStatus.UNVERIFIED,
)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
# Send verification email
await self.send_verification_email(user)
return user
async def send_verification_email(self, user: User) -> User:
"""Send verification email to user"""
# Check rate limiting
if user.email_verification_sent_at:
time_since_last = datetime.utcnow() - user.email_verification_sent_at
if time_since_last < timedelta(minutes=5):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Please wait before requesting another verification email"
)
# Generate verification token
verification_token = self.generate_verification_token()
user.email_verification_token = self.hash_token(verification_token)
user.email_verification_sent_at = datetime.utcnow()
self.db.commit()
# Create verification URL
verification_url = f"{self.settings.app.frontend_url}/verify-email?token={verification_token}"
# Send email
await self.email_service.send_verification_email(
to_email=user.email,
user_name=user.get_display_name(),
verification_url=verification_url
)
return user
async def verify_email(self, token: str) -> Tuple[User, Dict[str, Any]]:
"""Verify email with token"""
# Hash the token to find user
token_hash = self.hash_token(token)
user = self.user_repo.get_by_verification_token(token_hash)
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid verification token"
)
# Check if token has expired (24 hours)
if user.email_verification_sent_at:
time_since_sent = datetime.utcnow() - user.email_verification_sent_at
if time_since_sent > timedelta(hours=24):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verification token has expired"
)
# Verify email
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_verification_token = None
user.verification_status = VerificationStatus.EMAIL_VERIFIED
user.last_login_at = datetime.utcnow()
# Add user role
user_role = self.role_repo.get_by_name("user")
if user_role and user_role not in user.roles:
user.roles.append(user_role)
self.db.commit()
self.db.refresh(user)
# Create session token
access_token = self.create_session_token(user)
refresh_token = self.create_refresh_token(user)
return user, {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "Bearer",
"expires_in": self.settings.security.access_token_expire_minutes * 60,
"user": user.to_dict()
}
async def login_with_magic_link(self, email: str) -> None:
"""Send magic login link to email"""
user = self.user_repo.get_by_email(email)
if not user:
# Don't reveal if user exists
return
if user.auth_provider != AuthProvider.EMAIL:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Please use your configured authentication method"
)
# Generate login token
login_token = self.generate_verification_token()
user.email_verification_token = self.hash_token(login_token)
user.email_verification_sent_at = datetime.utcnow()
self.db.commit()
# Create login URL
login_url = f"{self.settings.app.frontend_url}/login-email?token={login_token}"
# Send email
await self.email_service.send_magic_link_email(
to_email=user.email,
user_name=user.get_display_name(),
login_url=login_url
)
async def verify_magic_link(self, token: str) -> Tuple[User, Dict[str, Any]]:
"""Verify magic link token and login user"""
# Hash the token to find user
token_hash = self.hash_token(token)
user = self.user_repo.get_by_verification_token(token_hash)
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid login token"
)
# Check if token has expired (15 minutes for login)
if user.email_verification_sent_at:
time_since_sent = datetime.utcnow() - user.email_verification_sent_at
if time_since_sent > timedelta(minutes=15):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Login token has expired"
)
# Clear token and update login time
user.email_verification_token = None
user.last_login_at = datetime.utcnow()
# If not yet verified, verify now
if not user.email_verified:
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.verification_status = VerificationStatus.EMAIL_VERIFIED
# Add user role
user_role = self.role_repo.get_by_name("user")
if user_role and user_role not in user.roles:
user.roles.append(user_role)
self.db.commit()
self.db.refresh(user)
# Create session tokens
access_token = self.create_session_token(user)
refresh_token = self.create_refresh_token(user)
return user, {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "Bearer",
"expires_in": self.settings.security.access_token_expire_minutes * 60,
"user": user.to_dict()
}
def create_session_token(self, user: User) -> str:
"""Create JWT session token"""
expire = datetime.utcnow() + timedelta(minutes=self.settings.security.access_token_expire_minutes)
payload = {
"sub": str(user.id),
"email": user.email,
"roles": [role.name for role in user.roles],
"exp": expire,
"iat": datetime.utcnow(),
"type": "access"
}
return jose_jwt.encode(
payload,
self.settings.security.jwt_secret_key,
algorithm=self.settings.security.jwt_algorithm
)
def create_refresh_token(self, user: User) -> str:
"""Create JWT refresh token"""
expire = datetime.utcnow() + timedelta(days=self.settings.security.refresh_token_expire_days)
payload = {
"sub": str(user.id),
"exp": expire,
"iat": datetime.utcnow(),
"type": "refresh"
}
return jose_jwt.encode(
payload,
self.settings.security.jwt_secret_key,
algorithm=self.settings.security.jwt_algorithm
)
async def resend_verification(self, email: str) -> None:
"""Resend verification email"""
user = self.user_repo.get_by_email(email)
if not user:
# Don't reveal if user exists
return
if user.auth_provider != AuthProvider.EMAIL:
return
if user.email_verified:
return
await self.send_verification_email(user)
def verify_token(self, token: str) -> Dict[str, Any]:
"""Verify JWT token and return payload"""
try:
payload = jose_jwt.decode(
token,
self.settings.security.jwt_secret_key,
algorithms=[self.settings.security.jwt_algorithm]
)
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
async def get_current_user(self, token: str) -> User:
"""Get current user from JWT token"""
payload = self.verify_token(token)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
user_id = payload.get("sub")
user = self.user_repo.get_by_id(int(user_id))
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Update last activity
user.last_activity_at = datetime.utcnow()
self.db.commit()
return user
async def update_user_profile(
self,
user: User,
given_name: Optional[str] = None,
family_name: Optional[str] = None,
display_name: Optional[str] = None
) -> User:
"""Update user profile information"""
if given_name is not None:
user.given_name = given_name
if family_name is not None:
user.family_name = family_name
if display_name is not None:
user.display_name = display_name
self.db.commit()
self.db.refresh(user)
return user

View File

@ -0,0 +1,454 @@
"""
OIDC/OAuth2 Authentication Service
This module provides OIDC/OAuth2 authentication with Nextcloud integration.
"""
import secrets
import hashlib
import jwt
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List, Tuple
from urllib.parse import urlencode, quote
import httpx
import logging
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from jose import JWTError, jwt as jose_jwt
from ..models.user import User, Role, AuthProvider, VerificationStatus
from ..models.user import Session as UserSession
from ..config.settings import Settings
from ..repositories.user import UserRepository
from ..repositories.role import RoleRepository
from ..utils.crypto import encrypt_token, decrypt_token
logger = logging.getLogger(__name__)
class OIDCConfig:
"""OIDC configuration for Nextcloud"""
def __init__(self, settings: Settings):
self.settings = settings
self.issuer = settings.oidc.issuer # e.g., "https://nextcloud.example.com"
self.client_id = settings.oidc.client_id
self.client_secret = settings.oidc.client_secret
self.redirect_uri = settings.oidc.redirect_uri
self.scope = settings.oidc.scope or "openid profile email"
# OIDC endpoints (Nextcloud specific)
self.authorization_endpoint = f"{self.issuer}/index.php/apps/oauth2/authorize"
self.token_endpoint = f"{self.issuer}/index.php/apps/oauth2/api/v1/token"
self.userinfo_endpoint = f"{self.issuer}/ocs/v2.php/cloud/user"
self.jwks_uri = f"{self.issuer}/index.php/apps/oauth2/jwks"
# Alternative standard OIDC discovery
self.discovery_endpoint = f"{self.issuer}/.well-known/openid-configuration"
class OIDCAuthService:
"""Service for handling OIDC/OAuth2 authentication"""
def __init__(self, db_session: Session, settings: Settings):
self.db = db_session
self.settings = settings
self.config = OIDCConfig(settings)
self.user_repo = UserRepository(db_session)
self.role_repo = RoleRepository(db_session)
self.http_client = httpx.AsyncClient()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.http_client.aclose()
def generate_state_token(self) -> str:
"""Generate a secure state token for OIDC flow"""
return secrets.token_urlsafe(32)
def generate_nonce(self) -> str:
"""Generate a secure nonce for OIDC flow"""
return secrets.token_urlsafe(32)
def get_authorization_url(self, state: str, nonce: str) -> str:
"""Get the OIDC authorization URL"""
params = {
"client_id": self.config.client_id,
"response_type": "code",
"scope": self.config.scope,
"redirect_uri": self.config.redirect_uri,
"state": state,
"nonce": nonce,
}
return f"{self.config.authorization_endpoint}?{urlencode(params)}"
async def exchange_code_for_tokens(self, code: str) -> Dict[str, Any]:
"""Exchange authorization code for tokens"""
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.config.redirect_uri,
"client_id": self.config.client_id,
"client_secret": self.config.client_secret,
}
try:
response = await self.http_client.post(
self.config.token_endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.error(f"Failed to exchange code for tokens: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code"
)
async def get_userinfo(self, access_token: str) -> Dict[str, Any]:
"""Get user information from OIDC provider"""
headers = {"Authorization": f"Bearer {access_token}"}
try:
# Try standard OIDC userinfo endpoint
response = await self.http_client.get(
self.config.userinfo_endpoint,
headers=headers
)
if response.status_code == 200:
return response.json()
# Fallback to Nextcloud OCS API
ocs_headers = {
**headers,
"OCS-APIRequest": "true",
"Accept": "application/json"
}
response = await self.http_client.get(
f"{self.config.issuer}/ocs/v2.php/cloud/user?format=json",
headers=ocs_headers
)
response.raise_for_status()
ocs_data = response.json()
if "ocs" in ocs_data and "data" in ocs_data["ocs"]:
user_data = ocs_data["ocs"]["data"]
# Map Nextcloud data to OIDC claims
return {
"sub": user_data.get("id"),
"email": user_data.get("email"),
"email_verified": bool(user_data.get("email")),
"name": user_data.get("display-name", user_data.get("displayname")),
"preferred_username": user_data.get("id"),
"groups": user_data.get("groups", []),
"quota": user_data.get("quota"),
"language": user_data.get("language"),
"locale": user_data.get("locale"),
}
return response.json()
except httpx.HTTPError as e:
logger.error(f"Failed to get user info: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to get user information"
)
async def authenticate_user(
self,
code: str,
state: str,
stored_state: str,
nonce: Optional[str] = None
) -> Tuple[User, Dict[str, Any]]:
"""Authenticate user with OIDC"""
# Verify state
if state != stored_state:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid state parameter"
)
# Exchange code for tokens
token_response = await self.exchange_code_for_tokens(code)
access_token = token_response.get("access_token")
refresh_token = token_response.get("refresh_token")
id_token = token_response.get("id_token")
expires_in = token_response.get("expires_in", 3600)
# Get user info
userinfo = await self.get_userinfo(access_token)
# Validate nonce if ID token is present
if id_token and nonce:
try:
# Decode without verification first to get claims
# In production, should verify against JWKS
claims = jose_jwt.decode(id_token, options={"verify_signature": False})
if claims.get("nonce") != nonce:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid nonce"
)
except JWTError:
logger.warning("Failed to decode ID token")
# Create or update user
user = await self.create_or_update_user(userinfo, access_token, refresh_token, expires_in)
# Create session
session_token = self.create_session_token(user)
return user, {
"access_token": session_token,
"token_type": "Bearer",
"expires_in": self.settings.security.access_token_expire_minutes * 60,
"user": user.to_dict()
}
async def create_or_update_user(
self,
userinfo: Dict[str, Any],
access_token: str,
refresh_token: Optional[str],
expires_in: int
) -> User:
"""Create or update user from OIDC claims"""
sub = userinfo.get("sub")
email = userinfo.get("email")
if not sub or not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing required user information"
)
# Try to find existing user
user = self.user_repo.get_by_oidc_sub(sub, self.config.issuer)
if not user:
# Try to find by email
user = self.user_repo.get_by_email(email)
if user and user.auth_provider != AuthProvider.OIDC:
# User exists with different auth provider
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already exists with different authentication method"
)
# Parse name fields
name = userinfo.get("name", "")
given_name = userinfo.get("given_name", "")
family_name = userinfo.get("family_name", "")
if name and not (given_name or family_name):
# Try to split name
name_parts = name.split(" ", 1)
given_name = name_parts[0]
family_name = name_parts[1] if len(name_parts) > 1 else ""
# Calculate token expiration
token_expires_at = datetime.utcnow() + timedelta(seconds=expires_in)
if user:
# Update existing user
user.oidc_sub = sub
user.oidc_issuer = self.config.issuer
user.given_name = given_name or user.given_name
user.family_name = family_name or user.family_name
user.preferred_username = userinfo.get("preferred_username", user.preferred_username)
user.display_name = name or user.display_name
user.picture_url = userinfo.get("picture", user.picture_url)
user.email_verified = userinfo.get("email_verified", False)
user.oidc_access_token = encrypt_token(access_token, self.settings.security.encryption_key)
user.oidc_refresh_token = encrypt_token(refresh_token, self.settings.security.encryption_key) if refresh_token else None
user.oidc_token_expires_at = token_expires_at
user.oidc_claims = userinfo
user.last_login_at = datetime.utcnow()
if user.email_verified:
user.verification_status = VerificationStatus.OIDC_VERIFIED
else:
# Create new user
user = User(
email=email,
auth_provider=AuthProvider.OIDC,
oidc_sub=sub,
oidc_issuer=self.config.issuer,
given_name=given_name,
family_name=family_name,
preferred_username=userinfo.get("preferred_username"),
display_name=name,
picture_url=userinfo.get("picture"),
email_verified=userinfo.get("email_verified", False),
verification_status=VerificationStatus.OIDC_VERIFIED if userinfo.get("email_verified") else VerificationStatus.UNVERIFIED,
oidc_access_token=encrypt_token(access_token, self.settings.security.encryption_key),
oidc_refresh_token=encrypt_token(refresh_token, self.settings.security.encryption_key) if refresh_token else None,
oidc_token_expires_at=token_expires_at,
oidc_claims=userinfo,
last_login_at=datetime.utcnow()
)
self.db.add(user)
# Map OIDC roles to application roles
await self.sync_user_roles(user, userinfo.get("groups", []))
self.db.commit()
self.db.refresh(user)
return user
async def sync_user_roles(self, user: User, oidc_groups: List[str]):
"""Sync user roles based on OIDC groups"""
# Get role mappings
role_mappings = self.role_repo.get_oidc_role_mappings()
# Clear existing non-system roles
user.roles = [role for role in user.roles if role.is_system]
# Add roles based on OIDC groups
for group in oidc_groups:
if group in role_mappings:
role = role_mappings[group]
if role not in user.roles:
user.roles.append(role)
# Special handling for admin groups
admin_groups = self.settings.oidc.admin_groups or []
if any(group in admin_groups for group in oidc_groups):
admin_role = self.role_repo.get_by_name("admin")
if admin_role and admin_role not in user.roles:
user.roles.append(admin_role)
# Special handling for specific roles
role_group_mapping = {
"haushaltsbeauftragte": self.settings.oidc.budget_reviewer_groups or [],
"finanzreferent": self.settings.oidc.finance_reviewer_groups or [],
"asta": self.settings.oidc.asta_groups or [],
}
for role_name, groups in role_group_mapping.items():
if any(group in groups for group in oidc_groups):
role = self.role_repo.get_by_name(role_name)
if role and role not in user.roles:
user.roles.append(role)
# Ensure every verified user has at least the user role
if user.verification_status != VerificationStatus.UNVERIFIED:
user_role = self.role_repo.get_by_name("user")
if user_role and user_role not in user.roles:
user.roles.append(user_role)
def create_session_token(self, user: User) -> str:
"""Create JWT session token"""
expire = datetime.utcnow() + timedelta(minutes=self.settings.security.access_token_expire_minutes)
payload = {
"sub": str(user.id),
"email": user.email,
"roles": [role.name for role in user.roles],
"exp": expire,
"iat": datetime.utcnow(),
"type": "access"
}
return jose_jwt.encode(
payload,
self.settings.security.jwt_secret_key,
algorithm=self.settings.security.jwt_algorithm
)
def create_refresh_token(self, user: User) -> str:
"""Create JWT refresh token"""
expire = datetime.utcnow() + timedelta(days=self.settings.security.refresh_token_expire_days)
payload = {
"sub": str(user.id),
"exp": expire,
"iat": datetime.utcnow(),
"type": "refresh"
}
return jose_jwt.encode(
payload,
self.settings.security.jwt_secret_key,
algorithm=self.settings.security.jwt_algorithm
)
async def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
"""Refresh access token using refresh token"""
try:
payload = jose_jwt.decode(
refresh_token,
self.settings.security.jwt_secret_key,
algorithms=[self.settings.security.jwt_algorithm]
)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
user_id = payload.get("sub")
user = self.user_repo.get_by_id(int(user_id))
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Create new access token
access_token = self.create_session_token(user)
return {
"access_token": access_token,
"token_type": "Bearer",
"expires_in": self.settings.security.access_token_expire_minutes * 60
}
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
async def logout(self, user: User, session_token: Optional[str] = None):
"""Logout user and invalidate session"""
if session_token:
# Invalidate specific session
session = self.db.query(UserSession).filter(
UserSession.session_token == session_token,
UserSession.user_id == user.id
).first()
if session:
self.db.delete(session)
else:
# Invalidate all user sessions
self.db.query(UserSession).filter(
UserSession.user_id == user.id
).delete()
self.db.commit()

View File

@ -0,0 +1,545 @@
"""
Base Service Layer
This module provides base service classes for implementing business logic.
All services should inherit from these base classes.
"""
from typing import Optional, Dict, Any, List, TypeVar, Generic
from abc import ABC, abstractmethod
import logging
from datetime import datetime
from contextlib import contextmanager
from ..config.settings import Settings, get_settings
from ..repositories.base import BaseRepository, RepositoryException
logger = logging.getLogger(__name__)
T = TypeVar('T')
class ServiceException(Exception):
"""Base exception for service layer errors"""
pass
class ValidationError(ServiceException):
"""Raised when validation fails"""
def __init__(self, message: str, errors: Optional[Dict[str, Any]] = None):
super().__init__(message)
self.errors = errors or {}
class BusinessRuleViolation(ServiceException):
"""Raised when a business rule is violated"""
pass
class ResourceNotFoundError(ServiceException):
"""Raised when a requested resource is not found"""
pass
class ResourceConflictError(ServiceException):
"""Raised when there's a conflict with existing resources"""
pass
class BaseService(ABC):
"""
Base service class providing common functionality for all services.
Services encapsulate business logic and coordinate between repositories,
external services, and other components.
"""
def __init__(self, settings: Optional[Settings] = None):
"""
Initialize service.
Args:
settings: Application settings
"""
self.settings = settings or get_settings()
self._logger = logging.getLogger(self.__class__.__name__)
@property
def logger(self) -> logging.Logger:
"""Get logger for this service"""
return self._logger
def log_info(self, message: str, **kwargs):
"""Log info message with context"""
self._logger.info(message, extra=kwargs)
def log_error(self, message: str, error: Optional[Exception] = None, **kwargs):
"""Log error message with context"""
if error:
self._logger.error(f"{message}: {str(error)}", exc_info=True, extra=kwargs)
else:
self._logger.error(message, extra=kwargs)
def log_warning(self, message: str, **kwargs):
"""Log warning message with context"""
self._logger.warning(message, extra=kwargs)
def log_debug(self, message: str, **kwargs):
"""Log debug message with context"""
self._logger.debug(message, extra=kwargs)
@contextmanager
def handle_errors(self, operation: str = "operation"):
"""
Context manager for handling service errors.
Args:
operation: Description of the operation being performed
Usage:
with self.handle_errors("creating user"):
# Service logic here
pass
"""
try:
yield
except ValidationError:
raise
except BusinessRuleViolation:
raise
except ResourceNotFoundError:
raise
except ResourceConflictError:
raise
except RepositoryException as e:
self.log_error(f"Repository error during {operation}", e)
raise ServiceException(f"Database error during {operation}: {str(e)}")
except Exception as e:
self.log_error(f"Unexpected error during {operation}", e)
raise ServiceException(f"Unexpected error during {operation}: {str(e)}")
def validate_required_fields(self, data: Dict[str, Any], required_fields: List[str]):
"""
Validate that required fields are present and not empty.
Args:
data: Data dictionary to validate
required_fields: List of required field names
Raises:
ValidationError: If validation fails
"""
errors = {}
for field in required_fields:
if field not in data or data[field] is None:
errors[field] = f"{field} is required"
elif isinstance(data[field], str) and not data[field].strip():
errors[field] = f"{field} cannot be empty"
if errors:
raise ValidationError("Validation failed", errors)
def validate_field_types(self, data: Dict[str, Any], field_types: Dict[str, type]):
"""
Validate field types.
Args:
data: Data dictionary to validate
field_types: Dictionary mapping field names to expected types
Raises:
ValidationError: If validation fails
"""
errors = {}
for field, expected_type in field_types.items():
if field in data and data[field] is not None:
if not isinstance(data[field], expected_type):
errors[field] = f"{field} must be of type {expected_type.__name__}"
if errors:
raise ValidationError("Type validation failed", errors)
def validate_field_values(self, data: Dict[str, Any], field_validators: Dict[str, callable]):
"""
Validate field values using custom validators.
Args:
data: Data dictionary to validate
field_validators: Dictionary mapping field names to validator functions
Raises:
ValidationError: If validation fails
"""
errors = {}
for field, validator in field_validators.items():
if field in data and data[field] is not None:
try:
if not validator(data[field]):
errors[field] = f"{field} validation failed"
except Exception as e:
errors[field] = str(e)
if errors:
raise ValidationError("Value validation failed", errors)
def sanitize_input(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Sanitize input data.
Args:
data: Input data to sanitize
Returns:
Sanitized data
"""
sanitized = {}
for key, value in data.items():
if isinstance(value, str):
# Strip whitespace
value = value.strip()
# Remove null bytes
value = value.replace('\x00', '')
sanitized[key] = value
return sanitized
def audit_log(
self,
action: str,
entity_type: str,
entity_id: Optional[Any] = None,
user: Optional[str] = None,
details: Optional[Dict[str, Any]] = None
):
"""
Create an audit log entry.
Args:
action: Action performed (e.g., "create", "update", "delete")
entity_type: Type of entity affected
entity_id: ID of the affected entity
user: User who performed the action
details: Additional details about the action
"""
log_entry = {
"timestamp": datetime.utcnow().isoformat(),
"action": action,
"entity_type": entity_type,
"entity_id": entity_id,
"user": user,
"details": details
}
self.log_info(f"Audit: {action} {entity_type}", **log_entry)
class CRUDService(BaseService, Generic[T]):
"""
Base service class for CRUD operations.
This class provides standard CRUD operations with business logic validation.
"""
def __init__(
self,
repository: BaseRepository[T],
settings: Optional[Settings] = None
):
"""
Initialize CRUD service.
Args:
repository: Repository for data access
settings: Application settings
"""
super().__init__(settings)
self.repository = repository
def create(
self,
data: Dict[str, Any],
user: Optional[str] = None,
validate: bool = True
) -> T:
"""
Create a new entity.
Args:
data: Entity data
user: User performing the action
validate: Whether to validate data
Returns:
Created entity
Raises:
ValidationError: If validation fails
ServiceException: If creation fails
"""
with self.handle_errors("creating entity"):
if validate:
self.validate_create(data)
# Sanitize input
data = self.sanitize_input(data)
# Create entity
entity = self._create_entity_from_data(data)
entity = self.repository.create(entity)
# Audit log
self.audit_log(
action="create",
entity_type=entity.__class__.__name__,
entity_id=entity.id,
user=user,
details={"data": data}
)
return entity
def update(
self,
id: Any,
data: Dict[str, Any],
user: Optional[str] = None,
validate: bool = True,
partial: bool = True
) -> T:
"""
Update an existing entity.
Args:
id: Entity ID
data: Update data
user: User performing the action
validate: Whether to validate data
partial: Whether this is a partial update
Returns:
Updated entity
Raises:
ResourceNotFoundError: If entity not found
ValidationError: If validation fails
ServiceException: If update fails
"""
with self.handle_errors("updating entity"):
# Get existing entity
entity = self.repository.get(id)
if not entity:
raise ResourceNotFoundError(f"Entity with id {id} not found")
if validate:
self.validate_update(data, entity, partial)
# Sanitize input
data = self.sanitize_input(data)
# Update entity
self._update_entity_from_data(entity, data)
entity = self.repository.update(entity)
# Audit log
self.audit_log(
action="update",
entity_type=entity.__class__.__name__,
entity_id=entity.id,
user=user,
details={"data": data}
)
return entity
def delete(
self,
id: Any,
user: Optional[str] = None,
soft: bool = True
) -> bool:
"""
Delete an entity.
Args:
id: Entity ID
user: User performing the action
soft: Whether to perform soft delete
Returns:
True if deleted successfully
Raises:
ResourceNotFoundError: If entity not found
ServiceException: If deletion fails
"""
with self.handle_errors("deleting entity"):
# Get existing entity
entity = self.repository.get(id)
if not entity:
raise ResourceNotFoundError(f"Entity with id {id} not found")
# Validate deletion
self.validate_delete(entity)
# Delete entity
if soft and hasattr(entity, 'soft_delete'):
entity.soft_delete()
self.repository.update(entity)
action = "soft_delete"
else:
self.repository.delete(entity)
action = "delete"
# Audit log
self.audit_log(
action=action,
entity_type=entity.__class__.__name__,
entity_id=id,
user=user
)
return True
def get(self, id: Any) -> Optional[T]:
"""
Get an entity by ID.
Args:
id: Entity ID
Returns:
Entity or None if not found
"""
with self.handle_errors("getting entity"):
return self.repository.get(id)
def get_or_404(self, id: Any) -> T:
"""
Get an entity by ID or raise error.
Args:
id: Entity ID
Returns:
Entity
Raises:
ResourceNotFoundError: If entity not found
"""
entity = self.get(id)
if not entity:
raise ResourceNotFoundError(f"Entity with id {id} not found")
return entity
def list(
self,
filters: Optional[Dict[str, Any]] = None,
page: int = 1,
page_size: int = 20,
order_by: Optional[str] = None,
order_desc: bool = False
) -> Dict[str, Any]:
"""
List entities with pagination.
Args:
filters: Filter criteria
page: Page number (1-based)
page_size: Number of items per page
order_by: Field to order by
order_desc: Whether to order descending
Returns:
Dictionary with items and pagination info
"""
with self.handle_errors("listing entities"):
# Calculate offset
offset = (page - 1) * page_size
# Get total count
total = self.repository.count(**(filters or {}))
# Get items
items = self.repository.find_all(
filters=filters,
limit=page_size,
offset=offset,
order_by=order_by,
order_desc=order_desc
)
# Calculate pagination info
total_pages = (total + page_size - 1) // page_size
return {
"items": items,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1
}
}
def search(
self,
query: str,
fields: List[str],
filters: Optional[Dict[str, Any]] = None,
page: int = 1,
page_size: int = 20
) -> Dict[str, Any]:
"""
Search entities.
Args:
query: Search query
fields: Fields to search in
filters: Additional filters
page: Page number
page_size: Number of items per page
Returns:
Search results with pagination
"""
# This is a basic implementation - override in specific services
# for more sophisticated search functionality
combined_filters = filters or {}
# Add search to filters (basic implementation)
if query and fields:
# This would need to be implemented based on your database
# For now, we'll just use the first field
combined_filters[fields[0]] = {"ilike": query}
return self.list(
filters=combined_filters,
page=page,
page_size=page_size
)
# Abstract methods to be implemented by subclasses
@abstractmethod
def validate_create(self, data: Dict[str, Any]):
"""Validate data for entity creation"""
pass
@abstractmethod
def validate_update(self, data: Dict[str, Any], entity: T, partial: bool = True):
"""Validate data for entity update"""
pass
def validate_delete(self, entity: T):
"""Validate entity deletion - override if needed"""
pass
@abstractmethod
def _create_entity_from_data(self, data: Dict[str, Any]) -> T:
"""Create entity instance from data"""
pass
@abstractmethod
def _update_entity_from_data(self, entity: T, data: Dict[str, Any]):
"""Update entity instance from data"""
pass

522
backend/src/services/pdf.py Normal file
View File

@ -0,0 +1,522 @@
"""
PDF Service
This module provides PDF processing services with dynamic variant support.
"""
from typing import Optional, Dict, Any, List, Type
from abc import ABC, abstractmethod
import base64
import io
import logging
from pathlib import Path
import PyPDF2
from PyPDF2.errors import PdfReadError
from .base import BaseService, ServiceException, ValidationError
from ..config.settings import Settings
logger = logging.getLogger(__name__)
class PDFVariantProvider(ABC):
"""Abstract base class for PDF variant providers"""
@abstractmethod
def get_variant_name(self) -> str:
"""Get the name of this variant (e.g., 'QSM', 'VSM')"""
pass
@abstractmethod
def get_template_path(self) -> Path:
"""Get the path to the PDF template for this variant"""
pass
@abstractmethod
def get_field_mapping(self) -> Dict[str, Any]:
"""Get the field mapping configuration for this variant"""
pass
@abstractmethod
def parse_pdf_fields(self, pdf_fields: Dict[str, Any]) -> Dict[str, Any]:
"""
Parse PDF form fields into a structured payload.
Args:
pdf_fields: Raw PDF form fields
Returns:
Structured payload
"""
pass
@abstractmethod
def map_payload_to_fields(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
Map a structured payload to PDF form fields.
Args:
payload: Structured payload
Returns:
PDF form fields
"""
pass
@abstractmethod
def validate_payload(self, payload: Dict[str, Any]) -> List[str]:
"""
Validate a payload for this variant.
Args:
payload: Payload to validate
Returns:
List of validation errors (empty if valid)
"""
pass
def detect_variant(self, pdf_fields: Dict[str, Any]) -> bool:
"""
Check if the given PDF fields match this variant.
Args:
pdf_fields: PDF form fields
Returns:
True if this variant matches
"""
# Default implementation - check for variant-specific fields
variant_indicators = self.get_variant_indicators()
for field in variant_indicators:
if field not in pdf_fields:
return False
return True
def get_variant_indicators(self) -> List[str]:
"""Get list of field names that indicate this variant"""
return []
def transform_value(self, value: Any, field_type: str) -> Any:
"""
Transform a value based on field type.
Args:
value: Value to transform
field_type: Type of the field
Returns:
Transformed value
"""
if field_type == "float":
try:
return float(value) if value else 0.0
except (TypeError, ValueError):
return 0.0
elif field_type == "int":
try:
return int(value) if value else 0
except (TypeError, ValueError):
return 0
elif field_type == "bool":
if isinstance(value, str):
return value.lower() in ["true", "yes", "1", "on", "ja"]
return bool(value)
elif field_type == "str":
return str(value) if value else ""
else:
return value
class PDFService(BaseService):
"""Service for PDF processing operations"""
def __init__(self, settings: Optional[Settings] = None):
"""
Initialize PDF service.
Args:
settings: Application settings
"""
super().__init__(settings)
self.providers: Dict[str, PDFVariantProvider] = {}
self._flattening_enabled = settings.pdf.enable_flattening if settings else True
self._flattening_method = settings.pdf.flattening_method if settings else "pymupdf"
# Register default providers
self._register_default_providers()
def _register_default_providers(self):
"""Register default PDF variant providers"""
# Import default providers if they exist
try:
from ..providers.pdf_qsm import QSMProvider
from ..providers.pdf_vsm import VSMProvider
self.register_provider(QSMProvider())
self.register_provider(VSMProvider())
except ImportError:
logger.warning("Default PDF providers not found")
def register_provider(self, provider: PDFVariantProvider):
"""
Register a PDF variant provider.
Args:
provider: Provider instance to register
"""
variant_name = provider.get_variant_name()
self.providers[variant_name.upper()] = provider
logger.info(f"Registered PDF provider for variant: {variant_name}")
def get_provider(self, variant: str) -> PDFVariantProvider:
"""
Get provider for a specific variant.
Args:
variant: Variant name
Returns:
Provider instance
Raises:
ValidationError: If variant not found
"""
variant = variant.upper()
if variant not in self.providers:
raise ValidationError(f"Unknown PDF variant: {variant}")
return self.providers[variant]
def parse_pdf(self, pdf_data: bytes, variant: Optional[str] = None) -> Dict[str, Any]:
"""
Parse PDF data into a structured payload.
Args:
pdf_data: PDF file data
variant: PDF variant (if known)
Returns:
Structured payload
Raises:
ValidationError: If PDF parsing fails
"""
try:
# Read PDF fields
with io.BytesIO(pdf_data) as bio:
reader = PyPDF2.PdfReader(bio)
pdf_fields = reader.get_fields() or {}
pdf_fields = {k: (v or {}) for k, v in pdf_fields.items()}
# Extract values from fields
field_values = {}
for field_name, field_data in pdf_fields.items():
if isinstance(field_data, dict) and "/V" in field_data:
value = field_data["/V"]
# Handle PyPDF2 name objects
if hasattr(value, "startswith") and value.startswith("/"):
value = value[1:]
field_values[field_name] = value
# Detect variant if not provided
if not variant:
variant = self.detect_variant(field_values)
# Get provider and parse
provider = self.get_provider(variant)
payload = provider.parse_pdf_fields(field_values)
# Validate payload
errors = provider.validate_payload(payload)
if errors:
raise ValidationError("PDF validation failed", {"errors": errors})
return payload
except PdfReadError as e:
raise ValidationError(f"Invalid PDF file: {str(e)}")
except Exception as e:
logger.error(f"Failed to parse PDF: {e}", exc_info=True)
raise ServiceException(f"Failed to parse PDF: {str(e)}")
def fill_pdf(
self,
payload: Dict[str, Any],
variant: str,
flatten: bool = True
) -> bytes:
"""
Fill a PDF template with data from a payload.
Args:
payload: Data payload
variant: PDF variant
flatten: Whether to flatten the PDF
Returns:
Filled PDF data
Raises:
ServiceException: If PDF filling fails
"""
try:
# Get provider
provider = self.get_provider(variant)
# Validate payload
errors = provider.validate_payload(payload)
if errors:
logger.warning(f"Payload validation warnings: {errors}")
# Map payload to PDF fields
field_values = provider.map_payload_to_fields(payload)
# Get template path
template_path = provider.get_template_path()
if not template_path.exists():
raise ServiceException(f"Template not found: {template_path}")
# Read template
with open(template_path, "rb") as f:
reader = PyPDF2.PdfReader(f)
writer = PyPDF2.PdfWriter()
# Copy pages and update form fields
for page in reader.pages:
writer.add_page(page)
# Update form fields
if reader.get_fields():
writer.update_page_form_field_values(
writer.pages[0],
field_values
)
# Generate output
output = io.BytesIO()
writer.write(output)
pdf_data = output.getvalue()
# Flatten if requested
if flatten and self._flattening_enabled:
pdf_data = self._flatten_pdf(pdf_data)
return pdf_data
except Exception as e:
logger.error(f"Failed to fill PDF: {e}", exc_info=True)
raise ServiceException(f"Failed to fill PDF: {str(e)}")
def _flatten_pdf(self, pdf_data: bytes) -> bytes:
"""
Flatten a PDF (make form fields non-editable).
Args:
pdf_data: PDF data to flatten
Returns:
Flattened PDF data
"""
if self._flattening_method == "pymupdf":
return self._flatten_with_pymupdf(pdf_data)
elif self._flattening_method == "pdftk":
return self._flatten_with_pdftk(pdf_data)
else:
logger.warning(f"Unknown flattening method: {self._flattening_method}")
return pdf_data
def _flatten_with_pymupdf(self, pdf_data: bytes) -> bytes:
"""Flatten PDF using PyMuPDF"""
try:
import fitz # PyMuPDF
# Open PDF
doc = fitz.open(stream=pdf_data, filetype="pdf")
# Create new PDF without forms
output = io.BytesIO()
new_doc = fitz.open()
for page in doc:
# Render page as image and add to new document
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
img_data = pix.tobytes("pdf")
img_doc = fitz.open(stream=img_data, filetype="pdf")
new_doc.insert_pdf(img_doc)
new_doc.save(output)
new_doc.close()
doc.close()
return output.getvalue()
except ImportError:
logger.warning("PyMuPDF not available, returning unflattened PDF")
return pdf_data
except Exception as e:
logger.error(f"Failed to flatten PDF with PyMuPDF: {e}")
return pdf_data
def _flatten_with_pdftk(self, pdf_data: bytes) -> bytes:
"""Flatten PDF using pdftk command line tool"""
import subprocess
import tempfile
try:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as input_file:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as output_file:
# Write input PDF
input_file.write(pdf_data)
input_file.flush()
# Run pdftk
cmd = [
self.settings.pdf.pdftk_path,
input_file.name,
"output",
output_file.name,
"flatten"
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
logger.error(f"pdftk failed: {result.stderr}")
return pdf_data
# Read flattened PDF
with open(output_file.name, "rb") as f:
return f.read()
except subprocess.TimeoutExpired:
logger.error("pdftk timeout")
return pdf_data
except Exception as e:
logger.error(f"Failed to flatten PDF with pdftk: {e}")
return pdf_data
def detect_variant(self, field_values: Dict[str, Any]) -> str:
"""
Detect PDF variant from field values.
Args:
field_values: PDF field values
Returns:
Detected variant name
Raises:
ValidationError: If variant cannot be detected
"""
for variant_name, provider in self.providers.items():
if provider.detect_variant(field_values):
logger.info(f"Detected PDF variant: {variant_name}")
return variant_name
# Default fallback
default_variant = self.settings.pdf.default_variant if self.settings else "QSM"
logger.warning(f"Could not detect variant, using default: {default_variant}")
return default_variant
def merge_pdfs(self, pdf_list: List[bytes]) -> bytes:
"""
Merge multiple PDFs into one.
Args:
pdf_list: List of PDF data bytes
Returns:
Merged PDF data
Raises:
ServiceException: If merge fails
"""
try:
writer = PyPDF2.PdfWriter()
for pdf_data in pdf_list:
with io.BytesIO(pdf_data) as bio:
reader = PyPDF2.PdfReader(bio)
for page in reader.pages:
writer.add_page(page)
output = io.BytesIO()
writer.write(output)
return output.getvalue()
except Exception as e:
logger.error(f"Failed to merge PDFs: {e}")
raise ServiceException(f"Failed to merge PDFs: {str(e)}")
def extract_text(self, pdf_data: bytes) -> str:
"""
Extract text content from PDF.
Args:
pdf_data: PDF data
Returns:
Extracted text
Raises:
ServiceException: If extraction fails
"""
try:
text_parts = []
with io.BytesIO(pdf_data) as bio:
reader = PyPDF2.PdfReader(bio)
for page in reader.pages:
text = page.extract_text()
if text:
text_parts.append(text)
return "\n".join(text_parts)
except Exception as e:
logger.error(f"Failed to extract text from PDF: {e}")
raise ServiceException(f"Failed to extract text: {str(e)}")
def get_pdf_info(self, pdf_data: bytes) -> Dict[str, Any]:
"""
Get metadata and information about a PDF.
Args:
pdf_data: PDF data
Returns:
PDF information dictionary
"""
try:
with io.BytesIO(pdf_data) as bio:
reader = PyPDF2.PdfReader(bio)
info = {
"num_pages": len(reader.pages),
"has_forms": bool(reader.get_fields()),
"is_encrypted": reader.is_encrypted,
"metadata": {}
}
# Extract metadata
if reader.metadata:
for key, value in reader.metadata.items():
if hasattr(value, "startswith"):
info["metadata"][key] = str(value)
# Get form field names if present
if info["has_forms"]:
fields = reader.get_fields() or {}
info["form_fields"] = list(fields.keys())
return info
except Exception as e:
logger.error(f"Failed to get PDF info: {e}")
return {"error": str(e)}

View File

@ -0,0 +1,562 @@
"""
PDF Template and Field Mapping Service
This module provides services for uploading PDF templates and managing field mappings.
"""
import os
import hashlib
import json
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple, BinaryIO
from datetime import datetime
import logging
from sqlalchemy.orm import Session
from fastapi import HTTPException, status, UploadFile
import pypdf
from pypdf.generic import NameObject, DictionaryObject, ArrayObject
import fitz # PyMuPDF for better PDF field extraction
from ..models.form_template import FormTemplate, FieldMapping, FormDesign, FormType, FieldType
from ..config.settings import Settings
from ..repositories.form_template import FormTemplateRepository
from ..utils.file_storage import FileStorageService
logger = logging.getLogger(__name__)
class PDFTemplateService:
"""Service for managing PDF templates and field mappings"""
def __init__(self, db_session: Session, settings: Settings):
self.db = db_session
self.settings = settings
self.template_repo = FormTemplateRepository(db_session)
self.storage = FileStorageService(settings.storage.upload_dir)
self.template_dir = Path(settings.storage.template_dir)
self.template_dir.mkdir(parents=True, exist_ok=True)
async def upload_pdf_template(
self,
file: UploadFile,
name: str,
display_name: str,
description: Optional[str] = None,
form_type: FormType = FormType.CUSTOM,
allowed_roles: Optional[List[str]] = None
) -> FormTemplate:
"""Upload a new PDF template and extract fields"""
# Validate file type
if not file.content_type or 'pdf' not in file.content_type.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be a PDF"
)
# Check if template name already exists
existing = self.template_repo.get_by_name(name)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template with this name already exists"
)
# Read file content
content = await file.read()
# Calculate file hash
file_hash = hashlib.sha256(content).hexdigest()
# Check if this PDF is already uploaded
existing_hash = self.template_repo.get_by_hash(file_hash)
if existing_hash:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This PDF file has already been uploaded"
)
# Save file to storage
file_path = self.template_dir / f"{name}_{file_hash[:8]}.pdf"
with open(file_path, 'wb') as f:
f.write(content)
# Extract form fields
fields = await self.extract_pdf_fields(content)
# Create template
template = FormTemplate(
name=name,
display_name=display_name,
description=description,
form_type=form_type,
pdf_file_path=str(file_path),
pdf_file_name=file.filename,
pdf_file_size=len(content),
pdf_file_hash=file_hash,
allowed_roles=allowed_roles or [],
is_active=True,
is_public=True,
requires_verification=True
)
self.db.add(template)
self.db.flush()
# Create field mappings
for idx, field_info in enumerate(fields):
mapping = FieldMapping(
template_id=template.id,
pdf_field_name=field_info['name'],
pdf_field_type=field_info['type'],
field_key=self.generate_field_key(field_info['name']),
field_label=self.generate_field_label(field_info['name']),
field_type=self.map_pdf_field_type(field_info['type']),
field_order=idx,
is_required=False,
is_readonly=field_info.get('readonly', False),
field_options=field_info.get('options', [])
)
self.db.add(mapping)
self.db.commit()
self.db.refresh(template)
return template
async def extract_pdf_fields(self, pdf_content: bytes) -> List[Dict[str, Any]]:
"""Extract form fields from PDF content"""
fields = []
try:
# Try with PyMuPDF first (better field extraction)
doc = fitz.open(stream=pdf_content, filetype="pdf")
for page_num in range(doc.page_count):
page = doc[page_num]
widgets = page.widgets()
for widget in widgets:
field_info = {
'name': widget.field_name,
'type': widget.field_type_string,
'page': page_num,
'rect': list(widget.rect),
'flags': widget.field_flags,
'value': widget.field_value,
'default': widget.field_default_value,
'readonly': bool(widget.field_flags & (1 << 0)), # Check readonly flag
'required': bool(widget.field_flags & (1 << 1)), # Check required flag
}
# Extract options for choice fields
if widget.field_type in [fitz.PDF_WIDGET_TYPE_COMBOBOX, fitz.PDF_WIDGET_TYPE_LISTBOX]:
field_info['options'] = widget.choice_values
# Extract checkbox states
if widget.field_type == fitz.PDF_WIDGET_TYPE_CHECKBOX:
field_info['states'] = ['Off', 'Yes'] # Standard checkbox states
fields.append(field_info)
doc.close()
except Exception as e:
logger.warning(f"PyMuPDF extraction failed, falling back to pypdf: {e}")
# Fallback to pypdf
try:
pdf = pypdf.PdfReader(content=pdf_content)
if '/AcroForm' in pdf.trailer['/Root']:
form = pdf.trailer['/Root']['/AcroForm']
if '/Fields' in form:
for field_ref in form['/Fields']:
field = field_ref.get_object()
field_info = self.extract_field_info(field)
if field_info:
fields.append(field_info)
except Exception as e:
logger.error(f"Failed to extract PDF fields: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to extract form fields from PDF"
)
return fields
def extract_field_info(self, field: DictionaryObject) -> Optional[Dict[str, Any]]:
"""Extract field information from PDF field object"""
try:
field_info = {
'name': field.get('/T', ''),
'type': self.get_field_type(field),
'value': field.get('/V', ''),
'default': field.get('/DV', ''),
'flags': field.get('/Ff', 0),
'readonly': bool(field.get('/Ff', 0) & 1),
'required': bool(field.get('/Ff', 0) & 2),
}
# Extract options for choice fields
if '/Opt' in field:
options = field['/Opt']
if isinstance(options, ArrayObject):
field_info['options'] = [str(opt) for opt in options]
# Extract additional properties
if '/TU' in field: # Tooltip
field_info['tooltip'] = field['/TU']
if '/Q' in field: # Text alignment
field_info['alignment'] = field['/Q']
if '/MaxLen' in field: # Maximum length
field_info['maxlength'] = field['/MaxLen']
return field_info
except Exception as e:
logger.warning(f"Failed to extract field info: {e}")
return None
def get_field_type(self, field: DictionaryObject) -> str:
"""Determine field type from PDF field object"""
ft = field.get('/FT')
if ft == '/Tx':
# Text field
if field.get('/Ff', 0) & (1 << 12): # Multiline flag
return 'textarea'
return 'text'
elif ft == '/Btn':
# Button field
if field.get('/Ff', 0) & (1 << 16): # Pushbutton flag
return 'button'
elif field.get('/Ff', 0) & (1 << 15): # Radio flag
return 'radio'
else:
return 'checkbox'
elif ft == '/Ch':
# Choice field
if field.get('/Ff', 0) & (1 << 17): # Combo flag
return 'combobox'
else:
return 'listbox'
elif ft == '/Sig':
return 'signature'
else:
return 'unknown'
def map_pdf_field_type(self, pdf_type: str) -> FieldType:
"""Map PDF field type to internal field type"""
mapping = {
'text': FieldType.TEXT,
'textarea': FieldType.TEXTAREA,
'checkbox': FieldType.CHECKBOX,
'radio': FieldType.RADIO,
'combobox': FieldType.SELECT,
'listbox': FieldType.SELECT,
'signature': FieldType.SIGNATURE,
'button': FieldType.TEXT, # Fallback for buttons
}
return mapping.get(pdf_type.lower(), FieldType.TEXT)
def generate_field_key(self, pdf_field_name: str) -> str:
"""Generate internal field key from PDF field name"""
# Clean up the field name
key = pdf_field_name.lower()
key = key.replace(' ', '_')
key = key.replace('-', '_')
key = ''.join(c if c.isalnum() or c == '_' else '' for c in key)
# Remove duplicate underscores
while '__' in key:
key = key.replace('__', '_')
return key.strip('_')
def generate_field_label(self, pdf_field_name: str) -> str:
"""Generate human-readable label from PDF field name"""
# Clean up the field name
label = pdf_field_name.replace('_', ' ').replace('-', ' ')
# Convert camelCase to spaces
import re
label = re.sub(r'([a-z])([A-Z])', r'\1 \2', label)
# Capitalize words
label = ' '.join(word.capitalize() for word in label.split())
return label
async def update_field_mapping(
self,
template_id: int,
field_mappings: List[Dict[str, Any]]
) -> FormTemplate:
"""Update field mappings for a template"""
template = self.template_repo.get_by_id(template_id)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
# Delete existing mappings
self.db.query(FieldMapping).filter(
FieldMapping.template_id == template_id
).delete()
# Create new mappings
for idx, mapping_data in enumerate(field_mappings):
mapping = FieldMapping(
template_id=template_id,
pdf_field_name=mapping_data['pdf_field_name'],
pdf_field_type=mapping_data.get('pdf_field_type'),
field_key=mapping_data['field_key'],
field_label=mapping_data['field_label'],
field_type=FieldType[mapping_data['field_type']],
field_order=mapping_data.get('field_order', idx),
is_required=mapping_data.get('is_required', False),
is_readonly=mapping_data.get('is_readonly', False),
is_hidden=mapping_data.get('is_hidden', False),
is_email_field=mapping_data.get('is_email_field', False),
is_name_field=mapping_data.get('is_name_field', False),
validation_rules=mapping_data.get('validation_rules', {}),
field_options=mapping_data.get('field_options', []),
default_value=mapping_data.get('default_value'),
placeholder=mapping_data.get('placeholder'),
help_text=mapping_data.get('help_text'),
display_conditions=mapping_data.get('display_conditions', {}),
transform_rules=mapping_data.get('transform_rules', {})
)
self.db.add(mapping)
self.db.commit()
self.db.refresh(template)
return template
async def get_field_mappings(self, template_id: int) -> List[Dict[str, Any]]:
"""Get field mappings for a template"""
template = self.template_repo.get_by_id(template_id)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
return [mapping.to_dict() for mapping in template.field_mappings]
async def test_field_mapping(
self,
template_id: int,
test_data: Dict[str, Any]
) -> Dict[str, Any]:
"""Test field mapping with sample data"""
template = self.template_repo.get_by_id(template_id)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
# Map test data to PDF fields
pdf_fields = {}
validation_errors = []
for mapping in template.field_mappings:
field_value = test_data.get(mapping.field_key)
# Apply transformations
if field_value is not None and mapping.transform_rules:
field_value = self.apply_transformations(field_value, mapping.transform_rules)
# Validate field
if mapping.is_required and not field_value:
validation_errors.append(f"Field '{mapping.field_label}' is required")
if field_value and mapping.validation_rules:
errors = self.validate_field(field_value, mapping.validation_rules)
if errors:
validation_errors.extend([f"{mapping.field_label}: {error}" for error in errors])
# Map to PDF field
if field_value is not None:
pdf_fields[mapping.pdf_field_name] = field_value
return {
'pdf_fields': pdf_fields,
'validation_errors': validation_errors,
'is_valid': len(validation_errors) == 0
}
def apply_transformations(self, value: Any, rules: Dict[str, Any]) -> Any:
"""Apply transformation rules to a field value"""
if 'uppercase' in rules and rules['uppercase']:
value = str(value).upper()
if 'lowercase' in rules and rules['lowercase']:
value = str(value).lower()
if 'trim' in rules and rules['trim']:
value = str(value).strip()
if 'format' in rules:
format_pattern = rules['format']
# Apply format pattern (e.g., phone number formatting)
# This would need more sophisticated implementation
return value
def validate_field(self, value: Any, rules: Dict[str, Any]) -> List[str]:
"""Validate field value against rules"""
errors = []
if 'min_length' in rules and len(str(value)) < rules['min_length']:
errors.append(f"Minimum length is {rules['min_length']}")
if 'max_length' in rules and len(str(value)) > rules['max_length']:
errors.append(f"Maximum length is {rules['max_length']}")
if 'pattern' in rules:
import re
if not re.match(rules['pattern'], str(value)):
errors.append("Value does not match required pattern")
if 'min_value' in rules and float(value) < rules['min_value']:
errors.append(f"Minimum value is {rules['min_value']}")
if 'max_value' in rules and float(value) > rules['max_value']:
errors.append(f"Maximum value is {rules['max_value']}")
return errors
async def delete_template(self, template_id: int) -> bool:
"""Delete a template and its associated files"""
template = self.template_repo.get_by_id(template_id)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
# Check if template is in use
if template.usage_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete template that has been used in applications"
)
# Delete PDF file
if template.pdf_file_path and os.path.exists(template.pdf_file_path):
try:
os.remove(template.pdf_file_path)
except Exception as e:
logger.error(f"Failed to delete PDF file: {e}")
# Delete from database
self.db.delete(template)
self.db.commit()
return True
async def duplicate_template(
self,
template_id: int,
new_name: str,
new_display_name: str
) -> FormTemplate:
"""Duplicate an existing template"""
source_template = self.template_repo.get_by_id(template_id)
if not source_template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Source template not found"
)
# Check if new name already exists
existing = self.template_repo.get_by_name(new_name)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template with this name already exists"
)
# Copy PDF file
if source_template.pdf_file_path and os.path.exists(source_template.pdf_file_path):
new_file_path = self.template_dir / f"{new_name}_{source_template.pdf_file_hash[:8]}.pdf"
import shutil
shutil.copy2(source_template.pdf_file_path, new_file_path)
else:
new_file_path = None
# Create new template
new_template = FormTemplate(
name=new_name,
display_name=new_display_name,
description=source_template.description,
form_type=source_template.form_type,
pdf_file_path=str(new_file_path) if new_file_path else None,
pdf_file_name=source_template.pdf_file_name,
pdf_file_size=source_template.pdf_file_size,
pdf_file_hash=source_template.pdf_file_hash,
allowed_roles=source_template.allowed_roles,
is_active=True,
is_public=source_template.is_public,
requires_verification=source_template.requires_verification,
form_design=source_template.form_design,
workflow_config=source_template.workflow_config,
parent_template_id=source_template.id,
version="1.0.0"
)
self.db.add(new_template)
self.db.flush()
# Copy field mappings
for mapping in source_template.field_mappings:
new_mapping = FieldMapping(
template_id=new_template.id,
pdf_field_name=mapping.pdf_field_name,
pdf_field_type=mapping.pdf_field_type,
field_key=mapping.field_key,
field_label=mapping.field_label,
field_type=mapping.field_type,
field_order=mapping.field_order,
is_required=mapping.is_required,
is_readonly=mapping.is_readonly,
is_hidden=mapping.is_hidden,
is_email_field=mapping.is_email_field,
is_name_field=mapping.is_name_field,
validation_rules=mapping.validation_rules,
field_options=mapping.field_options,
default_value=mapping.default_value,
placeholder=mapping.placeholder,
help_text=mapping.help_text,
display_conditions=mapping.display_conditions,
transform_rules=mapping.transform_rules
)
self.db.add(new_mapping)
self.db.commit()
self.db.refresh(new_template)
return new_template

View File

@ -28,6 +28,25 @@ services:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
networks:
- stupa_network
redis:
image: redis:7-alpine
container_name: stupa_redis
restart: unless-stopped
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- stupa_network
api:
build:
@ -39,36 +58,88 @@ services:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
# DB
# Database
MYSQL_HOST: db
MYSQL_PORT: 3306
MYSQL_DB: ${MYSQL_DB:-stupa}
MYSQL_USER: ${MYSQL_USER:-stupa}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-secret}
# Auth / Limits
# Redis
REDIS_HOST: redis
REDIS_PORT: 6379
# Security
MASTER_KEY: ${MASTER_KEY:-change_me}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change_me_jwt}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change_me_encryption}
# OIDC Settings
OIDC_ENABLED: ${OIDC_ENABLED:-false}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/callback}
OIDC_ADMIN_GROUPS: ${OIDC_ADMIN_GROUPS:-admin}
OIDC_BUDGET_REVIEWER_GROUPS: ${OIDC_BUDGET_REVIEWER_GROUPS:-haushaltsbeauftragte}
OIDC_FINANCE_REVIEWER_GROUPS: ${OIDC_FINANCE_REVIEWER_GROUPS:-finanzreferent}
OIDC_ASTA_GROUPS: ${OIDC_ASTA_GROUPS:-asta}
# Email Settings
EMAIL_ENABLED: ${EMAIL_ENABLED:-false}
SMTP_HOST: ${SMTP_HOST:-localhost}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-STUPA System}
# Rate Limiting
RATE_IP_PER_MIN: ${RATE_IP_PER_MIN:-60}
RATE_KEY_PER_MIN: ${RATE_KEY_PER_MIN:-30}
# PDF-Templates (liegen im Image in /app/assets)
QSM_TEMPLATE: /app/assets/qsm.pdf
VSM_TEMPLATE: /app/assets/vsm.pdf
# Optional: TZ
# Storage
UPLOAD_DIR: /app/uploads
TEMPLATE_DIR: /app/templates
ATTACHMENT_STORAGE: ${ATTACHMENT_STORAGE:-filesystem}
FILESYSTEM_PATH: /app/attachments
# Workflow
WORKFLOW_REQUIRED_VOTES: ${WORKFLOW_REQUIRED_VOTES:-5}
WORKFLOW_APPROVAL_THRESHOLD: ${WORKFLOW_APPROVAL_THRESHOLD:-50.0}
# Application
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3001}
ENVIRONMENT: ${ENVIRONMENT:-production}
DEBUG: ${DEBUG:-false}
TZ: ${TZ:-Europe/Berlin}
ports:
- "8000:8000"
# Healthcheck: ping FastAPI root
volumes:
- ./backend/uploads:/app/uploads
- ./backend/templates:/app/templates
- ./backend/attachments:/app/attachments
- pdf_forms:/app/pdf_forms
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/ || exit 1"]
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/health || exit 1"]
interval: 10s
timeout: 5s
retries: 6
networks:
- stupa_network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
network: host
args:
- VITE_API_URL=${VITE_API_URL:-http://localhost:8000}
- VITE_OIDC_ENABLED=${OIDC_ENABLED:-false}
- VITE_EMAIL_ENABLED=${EMAIL_ENABLED:-false}
container_name: stupa_frontend
restart: unless-stopped
depends_on:
@ -82,6 +153,29 @@ services:
interval: 10s
timeout: 5s
retries: 6
networks:
- stupa_network
form_designer:
image: node:18-alpine
container_name: stupa_form_designer
restart: unless-stopped
working_dir: /app
command: npm run dev
depends_on:
- api
ports:
- "3002:3000"
volumes:
- ./form-designer:/app
- /app/node_modules
environment:
- NODE_ENV=development
- VITE_API_URL=http://localhost:8000
networks:
- stupa_network
profiles:
- dev
adminer:
image: adminer:4
@ -92,8 +186,32 @@ services:
condition: service_healthy
environment:
ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: pepa-linha-dark
ports:
- "8080:8080"
networks:
- stupa_network
mailhog:
image: mailhog/mailhog:latest
container_name: stupa_mailhog
restart: unless-stopped
ports:
- "1025:1025" # SMTP server
- "8025:8025" # Web UI
networks:
- stupa_network
profiles:
- dev
volumes:
db_data:
driver: local
redis_data:
driver: local
pdf_forms:
driver: local
networks:
stupa_network:
driver: bridge