state: Partial Backend Rebuild

This commit is contained in:
Frederik Beimgraben 2025-09-17 02:09:18 +02:00
parent ad697e5f54
commit b41aa20948
38 changed files with 9328 additions and 1560 deletions

215
CHANGES.md Normal file
View File

@ -0,0 +1,215 @@
# Changes Summary - Dynamic Application System
## Overview
The application system has been completely redesigned to be fully dynamic and configurable. All application types, fields, statuses, and workflows are now defined in the database rather than hardcoded.
## Major Changes
### 1. Database Architecture
#### New Tables
- `application_types` - Defines application type templates
- `application_fields` - Field definitions for each type
- `application_type_statuses` - Status definitions per type
- `status_transitions` - Workflow transition rules
- `dynamic_applications` - Application instances
- `application_history_v2` - Complete audit trail
- `application_attachments_v2` - File attachments
- `application_transition_logs` - Status change tracking
- `application_approvals` - Approval decisions
#### Removed Tables
- `applications` (old fixed structure)
- `form_templates` (replaced by application_types)
- `field_mappings` (integrated into application_fields)
### 2. Core Features
#### Dynamic Field System
- 18+ field types (text, date, amount, etc.)
- Conditional display rules
- Custom validation per field
- Section grouping
- Default values and placeholders
#### Flexible Workflow
- Custom statuses with colors/icons
- Configurable transitions between statuses
- Multiple trigger types:
- User approval (with role requirements)
- Applicant actions
- Time-based triggers
- Condition-based triggers
- Automatic transitions
#### Enhanced Cost Management
- Up to 100 cost positions (previously 24)
- Up to 100 comparison offers (previously 24)
- Categories and notes per position
- Automatic total calculation
### 3. API Changes
#### New Endpoints
**Application Types:**
- `GET /api/application-types` - List all types
- `GET /api/application-types/{id}` - Get specific type
- `POST /api/application-types` - Create new type (admin)
- `PUT /api/application-types/{id}` - Update type (admin)
- `DELETE /api/application-types/{id}` - Delete/deactivate type
- `POST /api/application-types/{id}/pdf-template` - Upload PDF
**Dynamic Applications:**
- `GET /api/applications` - List with advanced filtering
- `GET /api/applications/{id}` - Get with access key support
- `POST /api/applications` - Create with type selection
- `PUT /api/applications/{id}` - Update with validation
- `POST /api/applications/{id}/submit` - Submit for review
- `POST /api/applications/{id}/transition` - Status change
- `POST /api/applications/{id}/approve` - Approval actions
- `GET /api/applications/{id}/history` - Audit trail
- `POST /api/applications/{id}/generate-pdf` - PDF generation
#### Removed Endpoints
- All QSM/VSM specific endpoints
- Fixed form template endpoints
- Legacy PDF processing endpoints
### 4. Models & Types
#### New TypeScript Types (`frontend/src/types/dynamic.ts`)
- `ApplicationType` - Type definition
- `FieldDefinition` - Field configuration
- `StatusDefinition` - Status configuration
- `TransitionDefinition` - Workflow rules
- `DynamicApplication` - Application instance
- `CostPosition` - Cost item structure
- `ComparisonOffer` - Vendor offer structure
#### New Python Models (`backend/src/models/application_type.py`)
- `ApplicationType` - Type ORM model
- `ApplicationField` - Field ORM model
- `ApplicationTypeStatus` - Status ORM model
- `StatusTransition` - Transition ORM model
- `DynamicApplication` - Application ORM model
- Supporting models for history, attachments, approvals
### 5. Services
#### New Services
- `NotificationService` - Email notifications with templates
- `PDFService` - Dynamic PDF generation
- `AuthService` - Enhanced authentication with roles
#### Enhanced Services
- Field validation with type-specific rules
- PDF template mapping and filling
- Workflow engine for transitions
- Audit logging for all changes
### 6. Frontend Updates
#### New Components (to be implemented)
- Dynamic field renderer
- Visual workflow designer
- Application type builder
- Status badge with colors
- Cost position manager (100 items)
- Comparison offer manager (100 items)
#### API Client (`frontend/src/api/dynamicClient.ts`)
- Full TypeScript support
- Automatic token refresh
- Public access support
- Error handling
- File upload support
### 7. Migration
#### Data Migration (`backend/scripts/migrate_to_dynamic.py`)
- Creates QSM and VSM as dynamic types
- Migrates existing applications
- Preserves all data and relationships
- Maintains audit trail
#### Migration Steps
1. Run database migration to create new tables
2. Execute migration script to create default types
3. Verify data integrity
4. Update frontend to use new endpoints
5. Remove old code and tables
### 8. Configuration
#### Environment Variables
```env
# New/Updated
MAX_COST_POSITIONS=100
MAX_COMPARISON_OFFERS=100
PDF_TEMPLATE_STORAGE=database
DYNAMIC_FIELD_VALIDATION=true
WORKFLOW_ENGINE_ENABLED=true
AUDIT_LOGGING_LEVEL=detailed
```
### 9. Benefits
#### For Administrators
- Create new application types without coding
- Visual workflow designer
- Flexible field configuration
- PDF template management
- Role-based access control
#### For Users
- Consistent interface across all types
- Better validation and help text
- Public access with keys
- Enhanced cost management
- Real-time status tracking
#### For Developers
- No hardcoded logic
- Extensible field types
- Clean separation of concerns
- Full TypeScript support
- Comprehensive audit trail
### 10. Breaking Changes
#### Backend
- All application endpoints changed
- Database schema completely redesigned
- Old models removed
- API response format changed
#### Frontend
- New type system required
- API client rewritten
- Component props changed
- State management updated
### 11. Upgrade Path
1. **Backup** all existing data
2. **Deploy** new backend with migrations
3. **Run** migration script
4. **Update** frontend to new API
5. **Test** thoroughly
6. **Remove** old code and tables
### 12. Future Enhancements
- Form templates and presets
- Batch operations
- Advanced reporting
- Mobile app support
- Webhook integrations
- Custom field types via plugins
- Multi-language support
- Advanced PDF templates with conditionals
## Summary
This update transforms the application system from a fixed, hardcoded structure to a fully dynamic, database-driven system. While this is a major breaking change, it provides unlimited flexibility for future requirements without code changes.

View File

@ -0,0 +1,374 @@
# Dynamic Application System Architecture
## Overview
This document describes the new fully dynamic application system that replaces the previous fixed QSM/VSM structure. The system now allows administrators to define any type of application with custom fields, statuses, and workflows.
## Core Concepts
### 1. Application Types
Application types are fully configurable templates that define:
- **Fields**: Dynamic field definitions with types, validation, and display rules
- **Statuses**: Custom status workflow with transitions
- **PDF Templates**: Optional PDF template with field mapping
- **Access Control**: Role-based access restrictions
- **Limits**: Maximum cost positions and comparison offers
### 2. Fields
Fields are the building blocks of applications with the following types:
- `text_short`: Short text input (max 255 chars)
- `text_long`: Long text/textarea
- `options`: Single selection from predefined options
- `yesno`: Boolean yes/no field
- `mail`: Email address with validation
- `date`: Date picker
- `datetime`: Date and time picker
- `amount`: Numeric amount field
- `currency_eur`: EUR currency field with formatting
- `number`: General numeric field
- `file`: File upload
- `signature`: Digital signature field
- `phone`: Phone number with validation
- `url`: URL with validation
- `checkbox`: Single checkbox
- `radio`: Radio button group
- `select`: Dropdown selection
- `multiselect`: Multiple selection
Each field supports:
- **Validation Rules**: min/max values, patterns, required status
- **Display Conditions**: Show/hide based on other field values
- **Default Values**: Pre-filled values
- **Placeholders & Help Text**: User guidance
### 3. Status System
Statuses define the workflow states:
- **Editability**: Whether the application can be edited
- **Visual Style**: Color and icon for UI
- **Notifications**: Email templates for status changes
- **Transitions**: Rules for moving between statuses
### 4. Transitions
Transitions define how applications move between statuses:
**Trigger Types:**
- `user_approval`: Requires N users with specific role to approve
- `applicant_action`: Button/action by the applicant
- `deadline_expired`: Automatic when a deadline passes
- `time_elapsed`: After a specific time period
- `condition_met`: When field conditions are satisfied
- `automatic`: Immediate automatic transition
## Database Schema
### Core Tables
1. **application_types**
- Defines application type templates
- Stores PDF template as BLOB
- Contains field mapping configuration
2. **application_fields**
- Field definitions for each type
- Validation rules as JSON
- Display conditions as JSON
3. **application_type_statuses**
- Status definitions per type
- Visual configuration (color, icon)
- Notification templates
4. **status_transitions**
- Transition rules between statuses
- Trigger configuration
- Conditions and actions
5. **dynamic_applications**
- Actual application instances
- Common fields (email, title, names)
- Dynamic field_data as JSON
- Cost positions and comparison offers as JSON
6. **application_history_v2**
- Complete audit trail
- Field-level change tracking
- User and IP tracking
7. **application_approvals**
- Approval decisions by role
- Comments and timestamps
## API Endpoints
### Application Types Management
```
GET /api/application-types - List all types
GET /api/application-types/{id} - Get specific type
POST /api/application-types - Create new type (admin)
PUT /api/application-types/{id} - Update type (admin)
DELETE /api/application-types/{id} - Delete type (admin)
POST /api/application-types/{id}/pdf-template - Upload PDF template
```
### Dynamic Applications
```
GET /api/applications - List applications
GET /api/applications/{id} - Get application details
POST /api/applications - Create new application
PUT /api/applications/{id} - Update application
POST /api/applications/{id}/submit - Submit for review
POST /api/applications/{id}/transition - Change status (admin)
POST /api/applications/{id}/approve - Approve/reject
GET /api/applications/{id}/history - Get history
POST /api/applications/{id}/generate-pdf - Generate PDF
```
## Common Fields
The following fields are always present (not dynamic):
1. **Email**: Applicant's email address
2. **Status**: Current workflow status
3. **Type**: Application type reference
4. **Title**: Application title/subject
5. **First Name**: Applicant's first name
6. **Last Name**: Applicant's last name
7. **Timestamps**: Created, submitted, status changed, completed
8. **Cost Positions**: Up to 100 items with description, amount, category
9. **Comparison Offers**: Up to 100 vendor offers
## Frontend Components
### Dynamic Field Renderer
The frontend includes a dynamic field rendering system that:
- Renders fields based on type
- Applies validation rules
- Handles display conditions
- Manages field dependencies
### Status Workflow UI
Visual workflow display showing:
- Current status with color/icon
- Available actions
- Transition history
- Approval tracking
### Admin Interface
Application type builder with:
- Drag-and-drop field designer
- Visual workflow editor
- PDF template mapper
- Role management
## Migration from Old System
### Data Migration Steps
1. **Create new tables** - Run migration script
2. **Define standard types** - Create QSM/VSM as dynamic types
3. **Map existing data** - Convert old applications to new format
4. **Update references** - Point to new tables
5. **Remove old tables** - Clean up after verification
### Field Mapping
Old QSM/VSM fields map to dynamic fields:
```json
{
"project.name": "project_name",
"applicant.name.first": "first_name",
"applicant.name.last": "last_name",
"applicant.contact.email": "email",
"project.costs": "cost_positions",
"project.totals.requestedAmountEur": "total_amount"
}
```
## Security & Access Control
### Role-Based Access
- **Admin**: Full access to type management and all applications
- **Budget Reviewer**: Review and approve budget-related applications
- **Finance Reviewer**: Financial review and approval
- **AStA Member**: Voting rights on applications
- **Applicant**: Create and edit own applications
### Public Access
Applications can be accessed via:
- **Authenticated**: Full access based on role
- **Access Key**: Limited access with unique key
- **Public Link**: Read-only access if configured
## Configuration
### Environment Variables
```env
# Database
DATABASE_URL=mysql://user:pass@localhost/stupa_db
# Email
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASSWORD=secret
FROM_EMAIL=noreply@example.com
FROM_NAME=Application System
# Security
JWT_SECRET_KEY=your-secret-key
ACCESS_TOKEN_EXPIRE_MINUTES=30
# Storage
PDF_OUTPUT_DIR=./uploads/pdfs
ATTACHMENT_DIR=./uploads/attachments
MAX_UPLOAD_SIZE=10485760
# Frontend
BASE_URL=https://applications.example.com
```
### Application Type Example
```json
{
"type_id": "travel_grant",
"name": "Travel Grant Application",
"description": "Apply for travel funding",
"fields": [
{
"field_id": "destination",
"field_type": "text_short",
"name": "Destination",
"is_required": true,
"validation_rules": {
"max_length": 100
}
},
{
"field_id": "purpose",
"field_type": "text_long",
"name": "Purpose of Travel",
"is_required": true,
"validation_rules": {
"min_length": 50,
"max_length": 500
}
},
{
"field_id": "travel_date",
"field_type": "date",
"name": "Travel Date",
"is_required": true,
"validation_rules": {
"min_date": "2024-01-01"
}
},
{
"field_id": "amount_requested",
"field_type": "currency_eur",
"name": "Amount Requested",
"is_required": true,
"validation_rules": {
"min": 0,
"max": 5000
}
}
],
"statuses": [
{
"status_id": "draft",
"name": "Draft",
"is_editable": true,
"color": "#6B7280",
"is_initial": true
},
{
"status_id": "submitted",
"name": "Submitted",
"is_editable": false,
"color": "#3B82F6",
"send_notification": true
},
{
"status_id": "approved",
"name": "Approved",
"is_editable": false,
"color": "#10B981",
"is_final": true
},
{
"status_id": "rejected",
"name": "Rejected",
"is_editable": false,
"color": "#EF4444",
"is_final": true
}
],
"transitions": [
{
"from_status_id": "draft",
"to_status_id": "submitted",
"name": "Submit Application",
"trigger_type": "applicant_action"
},
{
"from_status_id": "submitted",
"to_status_id": "approved",
"name": "Approve",
"trigger_type": "user_approval",
"trigger_config": {
"role": "admin",
"required_approvals": 1
}
},
{
"from_status_id": "submitted",
"to_status_id": "rejected",
"name": "Reject",
"trigger_type": "user_approval",
"trigger_config": {
"role": "admin",
"required_approvals": 1
}
}
]
}
```
## Advantages of Dynamic System
1. **Flexibility**: Create any type of application without code changes
2. **Maintainability**: All configuration in database, no hardcoded logic
3. **Scalability**: Same infrastructure handles all application types
4. **User Experience**: Consistent interface across all applications
5. **Compliance**: Built-in audit trail and approval workflows
6. **Integration**: PDF generation works with any template
7. **Future-Proof**: Easy to add new field types and features
## Performance Considerations
- **JSON Fields**: Indexed for fast searching
- **Caching**: Application types cached in memory
- **Lazy Loading**: Field data loaded on demand
- **Batch Operations**: Support for bulk status changes
- **Async Processing**: PDF generation in background
## Backup and Recovery
- **Daily Backups**: Automated database backups
- **Version History**: All changes tracked in history tables
- **Soft Deletes**: Applications marked as deleted, not removed
- **Export/Import**: JSON format for data portability

View File

@ -10,6 +10,7 @@ RUN apt-get update && apt-get install -y \
pkg-config \
wget \
curl \
netcat-openbsd \
# PDF processing tools
poppler-utils \
# Clean up
@ -38,14 +39,19 @@ RUN pip install --no-cache-dir \
# Copy application code
COPY src/ ./src/
COPY assets/ ./assets/
# Copy entrypoint script
COPY docker-entrypoint.sh /app/
RUN chmod +x /app/docker-entrypoint.sh
# Copy assets if they exist (currently no assets needed after removing LaTeX)
# COPY assets/ ./assets/
# Create necessary directories
RUN mkdir -p /app/uploads \
/app/templates \
/app/attachments \
/app/pdf_forms \
/app/logs
/app/logs \
/app/assets
# Set permissions
RUN chmod -R 755 /app
@ -58,4 +64,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
EXPOSE 8000
# Run the application
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
ENTRYPOINT ["/app/docker-entrypoint.sh"]

View File

@ -0,0 +1,34 @@
#!/bin/bash
set -e
echo "Starting STUPA PDF API Backend..."
# Wait for database to be ready
echo "Waiting for database..."
while ! nc -z ${MYSQL_HOST:-db} ${MYSQL_PORT:-3306}; do
sleep 1
done
echo "Database is ready!"
# Run database initialization
echo "Initializing database..."
python -m src.startup || {
echo "Warning: Database initialization failed or already initialized"
}
# Run migrations if alembic is available
if [ -f "alembic.ini" ]; then
echo "Running database migrations..."
alembic upgrade head || {
echo "Warning: Migrations failed or not configured"
}
fi
# Start the application
echo "Starting application server..."
exec uvicorn src.main:app \
--host 0.0.0.0 \
--port ${PORT:-8000} \
--workers ${WORKERS:-1} \
--reload-dir /app/src \
${UVICORN_ARGS}

View File

@ -25,6 +25,7 @@ requests-oauthlib==1.3.1
# PDF Processing
pypdf==3.17.4
PyPDF2==3.0.1
PyMuPDF==1.23.16
reportlab==4.0.8
pillow==10.2.0
@ -37,7 +38,6 @@ email-validator==2.1.0.post1
# Caching & Sessions
redis==5.0.1
python-redis==0.6.0
# Template Processing
jinja2==3.1.3
@ -60,7 +60,6 @@ openapi-schema-pydantic==1.2.4
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
@ -74,13 +73,10 @@ 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

View File

@ -0,0 +1,500 @@
#!/usr/bin/env python3
"""
Migration script to convert old application system to dynamic application system
This script:
1. Creates default QSM and VSM application types
2. Migrates existing applications to the new dynamic format
3. Preserves all data and relationships
"""
import os
import sys
import json
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from src.models.application_type import (
ApplicationType, ApplicationField, ApplicationTypeStatus,
StatusTransition, DynamicApplication, ApplicationHistory,
FieldType, TransitionTriggerType
)
from src.models.base import Base
from src.config.database import get_database_url
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def create_qsm_application_type(session) -> ApplicationType:
"""Create QSM application type with all fields"""
logger.info("Creating QSM application type...")
qsm_type = ApplicationType(
type_id="qsm",
name="QSM - Qualitätssicherungsmittel",
description="Antrag für Qualitätssicherungsmittel zur Verbesserung der Lehre",
is_active=True,
is_public=True,
max_cost_positions=100,
max_comparison_offers=100
)
session.add(qsm_type)
session.flush()
# Define QSM fields
qsm_fields = [
# Institution fields
{"field_id": "institution_type", "type": FieldType.SELECT, "name": "Art der Institution",
"options": ["Fachschaft", "STUPA-Referat", "Studentische Hochschulgruppe", "Fakultät", "Hochschuleinrichtung"],
"required": True, "order": 10},
{"field_id": "institution_name", "type": FieldType.TEXT_SHORT, "name": "Name der Institution",
"required": True, "order": 11},
# Applicant fields
{"field_id": "applicant_type", "type": FieldType.SELECT, "name": "Antragsteller",
"options": ["Person", "Institution"], "required": True, "order": 20},
{"field_id": "course", "type": FieldType.SELECT, "name": "Studiengang",
"options": ["INF", "ESB", "LS", "TEC", "TEX", "NXT"], "order": 21},
{"field_id": "role", "type": FieldType.SELECT, "name": "Rolle",
"options": ["Student", "Professor", "Mitarbeiter", "AStA", "Referatsleitung", "Fachschaftsvorstand"],
"order": 22},
{"field_id": "phone", "type": FieldType.PHONE, "name": "Telefonnummer", "order": 23},
# Project fields
{"field_id": "project_description", "type": FieldType.TEXT_LONG, "name": "Projektbeschreibung",
"required": True, "order": 30},
{"field_id": "project_start", "type": FieldType.DATE, "name": "Projektbeginn",
"required": True, "order": 31},
{"field_id": "project_end", "type": FieldType.DATE, "name": "Projektende", "order": 32},
{"field_id": "participants", "type": FieldType.NUMBER, "name": "Anzahl Teilnehmer", "order": 33},
# Participation
{"field_id": "faculty_inf", "type": FieldType.CHECKBOX, "name": "Fakultät INF", "order": 40},
{"field_id": "faculty_esb", "type": FieldType.CHECKBOX, "name": "Fakultät ESB", "order": 41},
{"field_id": "faculty_ls", "type": FieldType.CHECKBOX, "name": "Fakultät LS", "order": 42},
{"field_id": "faculty_tec", "type": FieldType.CHECKBOX, "name": "Fakultät TEC", "order": 43},
{"field_id": "faculty_tex", "type": FieldType.CHECKBOX, "name": "Fakultät TEX", "order": 44},
{"field_id": "faculty_nxt", "type": FieldType.CHECKBOX, "name": "Fakultät NxT", "order": 45},
{"field_id": "faculty_open", "type": FieldType.CHECKBOX, "name": "Fakultätsübergreifend", "order": 46},
# Financing
{"field_id": "qsm_code", "type": FieldType.SELECT, "name": "QSM-Code",
"options": [
"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-2-1: Verbesserung/Ausbau von Serviceeinrichtungen",
"vwv-3-2-2-2: Lehr- und Lernmaterialien",
"vwv-3-2-2-3: Durchführung von Exkursionen",
"vwv-3-2-2-4: Infrastrukturelle Begleit- und Anpassungsmaßnahmen",
"vwv-3-2-3-1: Verbesserung der Beratungsangebote",
"vwv-3-2-3-2: Studium Generale und fachübergreifende Lehrangebote",
"vwv-3-2-3-3: Sonstige Maßnahmen im Interesse der Studierendenschaft"
],
"required": True, "order": 50},
{"field_id": "qsm_stellenfinanzierungen", "type": FieldType.CHECKBOX,
"name": "Stellenfinanzierungen", "order": 51},
{"field_id": "qsm_studierende", "type": FieldType.CHECKBOX,
"name": "Für Studierende", "order": 52},
{"field_id": "qsm_individuell", "type": FieldType.CHECKBOX,
"name": "Individuelle Maßnahme", "order": 53},
{"field_id": "qsm_exkursion_genehmigt", "type": FieldType.CHECKBOX,
"name": "Exkursion genehmigt", "order": 54},
{"field_id": "qsm_exkursion_bezuschusst", "type": FieldType.CHECKBOX,
"name": "Exkursion bezuschusst", "order": 55},
# Attachments
{"field_id": "comparative_offers", "type": FieldType.CHECKBOX,
"name": "Vergleichsangebote vorhanden", "order": 60},
{"field_id": "fakultaet_attachment", "type": FieldType.CHECKBOX,
"name": "Fakultätsbeschluss angehängt", "order": 61},
]
for field_def in qsm_fields:
field = ApplicationField(
application_type_id=qsm_type.id,
field_id=field_def["field_id"],
field_type=field_def["type"],
name=field_def["name"],
field_order=field_def.get("order", 0),
is_required=field_def.get("required", False),
options=field_def.get("options"),
validation_rules=field_def.get("validation", {})
)
session.add(field)
return qsm_type
def create_vsm_application_type(session) -> ApplicationType:
"""Create VSM application type with all fields"""
logger.info("Creating VSM application type...")
vsm_type = ApplicationType(
type_id="vsm",
name="VSM - Verfasste Studierendenschaft",
description="Antrag für Mittel der Verfassten Studierendenschaft",
is_active=True,
is_public=True,
max_cost_positions=100,
max_comparison_offers=100
)
session.add(vsm_type)
session.flush()
# Define VSM fields (similar to QSM but with VSM-specific financing)
vsm_fields = [
# Institution fields
{"field_id": "institution_type", "type": FieldType.SELECT, "name": "Art der Institution",
"options": ["Fachschaft", "STUPA-Referat", "Studentische Hochschulgruppe"],
"required": True, "order": 10},
{"field_id": "institution_name", "type": FieldType.TEXT_SHORT, "name": "Name der Institution",
"required": True, "order": 11},
# Applicant fields (same as QSM)
{"field_id": "applicant_type", "type": FieldType.SELECT, "name": "Antragsteller",
"options": ["Person", "Institution"], "required": True, "order": 20},
{"field_id": "course", "type": FieldType.SELECT, "name": "Studiengang",
"options": ["INF", "ESB", "LS", "TEC", "TEX", "NXT"], "order": 21},
{"field_id": "role", "type": FieldType.SELECT, "name": "Rolle",
"options": ["Student", "AStA", "Referatsleitung", "Fachschaftsvorstand"],
"order": 22},
{"field_id": "phone", "type": FieldType.PHONE, "name": "Telefonnummer", "order": 23},
# Project fields (same as QSM)
{"field_id": "project_description", "type": FieldType.TEXT_LONG, "name": "Projektbeschreibung",
"required": True, "order": 30},
{"field_id": "project_start", "type": FieldType.DATE, "name": "Projektbeginn",
"required": True, "order": 31},
{"field_id": "project_end", "type": FieldType.DATE, "name": "Projektende", "order": 32},
{"field_id": "participants", "type": FieldType.NUMBER, "name": "Anzahl Teilnehmer", "order": 33},
# VSM-specific financing
{"field_id": "vsm_code", "type": FieldType.SELECT, "name": "VSM-Code",
"options": [
"lhg-01: Hochschulpolitische, fachliche, soziale, wirtschaftliche und kulturelle Belange",
"lhg-02: Mitwirkung an den Aufgaben der Hochschulen",
"lhg-03: Politische Bildung",
"lhg-04: Förderung der Chancengleichheit",
"lhg-05: Förderung der Integration ausländischer Studierender",
"lhg-06: Förderung der sportlichen Aktivitäten",
"lhg-07: Pflege der überregionalen Studierendenbeziehungen"
],
"required": True, "order": 50},
{"field_id": "vsm_aufgaben", "type": FieldType.CHECKBOX,
"name": "Aufgaben der Studierendenschaft", "order": 51},
{"field_id": "vsm_individuell", "type": FieldType.CHECKBOX,
"name": "Individuelle Maßnahme", "order": 52},
# Attachments
{"field_id": "comparative_offers", "type": FieldType.CHECKBOX,
"name": "Vergleichsangebote vorhanden", "order": 60},
]
for field_def in vsm_fields:
field = ApplicationField(
application_type_id=vsm_type.id,
field_id=field_def["field_id"],
field_type=field_def["type"],
name=field_def["name"],
field_order=field_def.get("order", 0),
is_required=field_def.get("required", False),
options=field_def.get("options"),
validation_rules=field_def.get("validation", {})
)
session.add(field)
return vsm_type
def create_statuses_and_transitions(session, app_type: ApplicationType):
"""Create standard statuses and transitions for an application type"""
logger.info(f"Creating statuses and transitions for {app_type.name}...")
# Define standard statuses
statuses = [
{"id": "draft", "name": "Entwurf", "editable": True, "color": "#6B7280",
"initial": True, "final": False},
{"id": "submitted", "name": "Beantragt", "editable": False, "color": "#3B82F6",
"initial": False, "final": False, "notification": True},
{"id": "processing_locked", "name": "Bearbeitung gesperrt", "editable": False,
"color": "#F59E0B", "initial": False, "final": False},
{"id": "under_review", "name": "Zu prüfen", "editable": False, "color": "#8B5CF6",
"initial": False, "final": False},
{"id": "voting", "name": "Zur Abstimmung", "editable": False, "color": "#EC4899",
"initial": False, "final": False},
{"id": "approved", "name": "Genehmigt", "editable": False, "color": "#10B981",
"initial": False, "final": True, "notification": True},
{"id": "rejected", "name": "Abgelehnt", "editable": False, "color": "#EF4444",
"initial": False, "final": True, "notification": True},
{"id": "cancelled", "name": "Zurückgezogen", "editable": False, "color": "#9CA3AF",
"initial": False, "final": True, "cancelled": True},
]
status_objects = {}
for i, status_def in enumerate(statuses):
status = ApplicationTypeStatus(
application_type_id=app_type.id,
status_id=status_def["id"],
name=status_def["name"],
is_editable=status_def["editable"],
color=status_def["color"],
display_order=i * 10,
is_initial=status_def.get("initial", False),
is_final=status_def.get("final", False),
is_cancelled=status_def.get("cancelled", False),
send_notification=status_def.get("notification", False)
)
session.add(status)
session.flush()
status_objects[status_def["id"]] = status
# Define transitions
transitions = [
# From Draft
{"from": "draft", "to": "submitted", "name": "Antrag einreichen",
"trigger": TransitionTriggerType.APPLICANT_ACTION},
# From Submitted
{"from": "submitted", "to": "processing_locked", "name": "Bearbeitung sperren",
"trigger": TransitionTriggerType.USER_APPROVAL, "role": "admin"},
{"from": "submitted", "to": "under_review", "name": "Zur Prüfung freigeben",
"trigger": TransitionTriggerType.USER_APPROVAL, "role": "admin"},
{"from": "submitted", "to": "cancelled", "name": "Zurückziehen",
"trigger": TransitionTriggerType.APPLICANT_ACTION},
# From Processing Locked
{"from": "processing_locked", "to": "under_review", "name": "Bearbeitung entsperren",
"trigger": TransitionTriggerType.USER_APPROVAL, "role": "admin"},
# From Under Review
{"from": "under_review", "to": "voting", "name": "Zur Abstimmung freigeben",
"trigger": TransitionTriggerType.USER_APPROVAL, "role": "budget_reviewer"},
{"from": "under_review", "to": "rejected", "name": "Ablehnen",
"trigger": TransitionTriggerType.USER_APPROVAL, "role": "budget_reviewer"},
# From Voting
{"from": "voting", "to": "approved", "name": "Genehmigen",
"trigger": TransitionTriggerType.USER_APPROVAL, "role": "asta_member",
"required": 3}, # Requires 3 AStA members to approve
{"from": "voting", "to": "rejected", "name": "Ablehnen",
"trigger": TransitionTriggerType.USER_APPROVAL, "role": "asta_member",
"required": 3}, # Requires 3 AStA members to reject
]
for trans_def in transitions:
config = {"role": trans_def.get("role", "admin")}
if "required" in trans_def:
config["required_approvals"] = trans_def["required"]
transition = StatusTransition(
from_status_id=status_objects[trans_def["from"]].id,
to_status_id=status_objects[trans_def["to"]].id,
name=trans_def["name"],
trigger_type=trans_def["trigger"],
trigger_config=config,
is_active=True
)
session.add(transition)
def migrate_old_application(session, old_app: Dict[str, Any], app_type: ApplicationType) -> DynamicApplication:
"""Migrate an old application to the new dynamic format"""
# Extract data from old format
payload = old_app.get("payload", {})
pa = payload.get("pa", {})
applicant = pa.get("applicant", {})
project = pa.get("project", {})
# Map old status to new status
status_map = {
"DRAFT": "draft",
"BEANTRAGT": "submitted",
"BEARBEITUNG_GESPERRT": "processing_locked",
"ZU_PRUEFEN": "under_review",
"ZUR_ABSTIMMUNG": "voting",
"GENEHMIGT": "approved",
"ABGELEHNT": "rejected",
"CANCELLED": "cancelled"
}
# Build field data
field_data = {}
# Institution fields
institution = applicant.get("institution", {})
field_data["institution_type"] = institution.get("type", "")
field_data["institution_name"] = institution.get("name", "")
# Applicant fields
field_data["applicant_type"] = applicant.get("type", "person")
name = applicant.get("name", {})
contact = applicant.get("contact", {})
field_data["course"] = applicant.get("course", "")
field_data["role"] = applicant.get("role", "")
field_data["phone"] = contact.get("phone", "")
# Project fields
field_data["project_description"] = project.get("description", "")
dates = project.get("dates", {})
field_data["project_start"] = dates.get("start", "")
field_data["project_end"] = dates.get("end", "")
field_data["participants"] = project.get("participants", 0)
# Participation
participation = project.get("participation", {})
faculties = participation.get("faculties", {})
field_data["faculty_inf"] = faculties.get("inf", False)
field_data["faculty_esb"] = faculties.get("esb", False)
field_data["faculty_ls"] = faculties.get("ls", False)
field_data["faculty_tec"] = faculties.get("tec", False)
field_data["faculty_tex"] = faculties.get("tex", False)
field_data["faculty_nxt"] = faculties.get("nxt", False)
field_data["faculty_open"] = faculties.get("open", False)
# Financing
financing = project.get("financing", {})
if app_type.type_id == "qsm":
qsm = financing.get("qsm", {})
field_data["qsm_code"] = qsm.get("code", "")
flags = qsm.get("flags", {})
field_data["qsm_stellenfinanzierungen"] = flags.get("stellenfinanzierungen", False)
field_data["qsm_studierende"] = flags.get("studierende", False)
field_data["qsm_individuell"] = flags.get("individuell", False)
field_data["qsm_exkursion_genehmigt"] = flags.get("exkursionGenehmigt", False)
field_data["qsm_exkursion_bezuschusst"] = flags.get("exkursionBezuschusst", False)
else: # VSM
vsm = financing.get("vsm", {})
field_data["vsm_code"] = vsm.get("code", "")
flags = vsm.get("flags", {})
field_data["vsm_aufgaben"] = flags.get("aufgaben", False)
field_data["vsm_individuell"] = flags.get("individuell", False)
# Attachments
attachments = pa.get("attachments", {})
field_data["comparative_offers"] = attachments.get("comparativeOffers", False)
if app_type.type_id == "qsm":
field_data["fakultaet_attachment"] = attachments.get("fakultaet", False)
# Cost positions
costs = project.get("costs", [])
cost_positions = []
for cost in costs:
cost_positions.append({
"description": cost.get("name", ""),
"amount": cost.get("amountEur", 0),
"category": "",
"notes": ""
})
# Create new dynamic application
new_app = DynamicApplication(
application_id=old_app["pa_id"],
application_key=old_app["pa_key"],
application_type_id=app_type.id,
user_id=old_app.get("user_id"),
email=contact.get("email", ""),
status_id=status_map.get(old_app.get("status", "DRAFT"), "draft"),
title=project.get("name", ""),
first_name=name.get("first", ""),
last_name=name.get("last", ""),
field_data=field_data,
cost_positions=cost_positions,
total_amount=project.get("totals", {}).get("requestedAmountEur", 0),
submitted_at=old_app.get("submitted_at"),
created_at=old_app.get("created_at", datetime.utcnow()),
updated_at=old_app.get("updated_at", datetime.utcnow())
)
return new_app
def main():
"""Main migration function"""
logger.info("Starting migration to dynamic application system...")
# Create database connection
engine = create_engine(get_database_url())
Session = sessionmaker(bind=engine)
session = Session()
try:
# Step 1: Create application types
qsm_type = create_qsm_application_type(session)
vsm_type = create_vsm_application_type(session)
session.commit()
logger.info("Application types created successfully")
# Step 2: Create statuses and transitions
create_statuses_and_transitions(session, qsm_type)
create_statuses_and_transitions(session, vsm_type)
session.commit()
logger.info("Statuses and transitions created successfully")
# Step 3: Migrate existing applications
logger.info("Migrating existing applications...")
# Query old applications (if table exists)
try:
result = session.execute(text("SELECT * FROM applications"))
old_applications = result.fetchall()
migrated_count = 0
for old_app_row in old_applications:
old_app = dict(old_app_row._mapping)
# Determine type based on variant
variant = old_app.get("variant", "QSM")
app_type = qsm_type if variant == "QSM" else vsm_type
# Migrate application
new_app = migrate_old_application(session, old_app, app_type)
session.add(new_app)
# Create history entry
history = ApplicationHistory(
application_id=new_app.id,
action="migrated",
comment=f"Migrated from old {variant} application",
created_at=datetime.utcnow()
)
session.add(history)
migrated_count += 1
if migrated_count % 100 == 0:
session.commit()
logger.info(f"Migrated {migrated_count} applications...")
session.commit()
logger.info(f"Successfully migrated {migrated_count} applications")
except Exception as e:
logger.warning(f"Could not migrate old applications: {e}")
logger.info("This is normal if running on a fresh database")
logger.info("Migration completed successfully!")
except Exception as e:
logger.error(f"Migration failed: {e}")
session.rollback()
raise
finally:
session.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,611 @@
"""
API routes for dynamic application type management
"""
from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Form
from sqlalchemy.orm import Session
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
import json
from ..config.database import get_db
from ..models.application_type import (
ApplicationType, ApplicationField, ApplicationTypeStatus,
StatusTransition, FieldType, TransitionTriggerType
)
from ..models.user import User
from ..services.auth_service import get_current_user, require_admin
from ..utils.pdf_utils import validate_pdf_template, extract_pdf_fields
router = APIRouter(prefix="/application-types", tags=["Application Types"])
# Pydantic models
class FieldDefinition(BaseModel):
field_id: str
field_type: str
name: str
label: Optional[str] = None
description: Optional[str] = None
field_order: int = 0
is_required: bool = False
is_readonly: bool = False
is_hidden: bool = False
options: Optional[List[str]] = None
default_value: Optional[str] = None
validation_rules: Optional[Dict[str, Any]] = None
display_conditions: Optional[Dict[str, Any]] = None
placeholder: Optional[str] = None
section: Optional[str] = None
class StatusDefinition(BaseModel):
status_id: str
name: str
description: Optional[str] = None
is_editable: bool = True
color: Optional[str] = None
icon: Optional[str] = None
display_order: int = 0
is_initial: bool = False
is_final: bool = False
is_cancelled: bool = False
send_notification: bool = False
notification_template: Optional[str] = None
class TransitionDefinition(BaseModel):
from_status_id: str
to_status_id: str
name: str
trigger_type: str
trigger_config: Dict[str, Any] = Field(default_factory=dict)
conditions: Optional[Dict[str, Any]] = None
actions: Optional[List[Dict[str, Any]]] = None
priority: int = 0
is_active: bool = True
class ApplicationTypeCreate(BaseModel):
type_id: str
name: str
description: Optional[str] = None
fields: List[FieldDefinition]
statuses: List[StatusDefinition]
transitions: List[TransitionDefinition]
pdf_field_mapping: Dict[str, str] = Field(default_factory=dict)
is_active: bool = True
is_public: bool = True
allowed_roles: Optional[List[str]] = None
max_cost_positions: int = 100
max_comparison_offers: int = 100
class ApplicationTypeUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
is_public: Optional[bool] = None
allowed_roles: Optional[List[str]] = None
max_cost_positions: Optional[int] = None
max_comparison_offers: Optional[int] = None
class ApplicationTypeResponse(BaseModel):
id: int
type_id: str
name: str
description: Optional[str]
is_active: bool
is_public: bool
allowed_roles: List[str]
max_cost_positions: int
max_comparison_offers: int
version: str
usage_count: int
pdf_template_filename: Optional[str]
fields: List[FieldDefinition]
statuses: List[StatusDefinition]
transitions: List[TransitionDefinition]
created_at: str
updated_at: str
class Config:
from_attributes = True
@router.get("/", response_model=List[ApplicationTypeResponse])
async def get_application_types(
include_inactive: bool = False,
current_user: Optional[User] = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all application types"""
query = db.query(ApplicationType)
if not include_inactive:
query = query.filter(ApplicationType.is_active == True)
# Non-admin users only see public types or types they have access to
if current_user and not current_user.has_role("admin"):
query = query.filter(
(ApplicationType.is_public == True) |
(ApplicationType.allowed_roles.contains([role.name for role in current_user.roles]))
)
elif not current_user:
# Anonymous users only see public types
query = query.filter(ApplicationType.is_public == True)
types = query.all()
result = []
for app_type in types:
type_dict = {
"id": app_type.id,
"type_id": app_type.type_id,
"name": app_type.name,
"description": app_type.description,
"is_active": app_type.is_active,
"is_public": app_type.is_public,
"allowed_roles": app_type.allowed_roles or [],
"max_cost_positions": app_type.max_cost_positions,
"max_comparison_offers": app_type.max_comparison_offers,
"version": app_type.version,
"usage_count": app_type.usage_count,
"pdf_template_filename": app_type.pdf_template_filename,
"created_at": app_type.created_at.isoformat(),
"updated_at": app_type.updated_at.isoformat(),
"fields": [],
"statuses": [],
"transitions": []
}
# Add fields
for field in app_type.fields:
type_dict["fields"].append({
"field_id": field.field_id,
"field_type": field.field_type.value,
"name": field.name,
"label": field.label,
"description": field.description,
"field_order": field.field_order,
"is_required": field.is_required,
"is_readonly": field.is_readonly,
"is_hidden": field.is_hidden,
"options": field.options,
"default_value": field.default_value,
"validation_rules": field.validation_rules,
"display_conditions": field.display_conditions,
"placeholder": field.placeholder,
"section": field.section
})
# Add statuses and transitions
status_map = {}
for status in app_type.statuses:
status_dict = {
"status_id": status.status_id,
"name": status.name,
"description": status.description,
"is_editable": status.is_editable,
"color": status.color,
"icon": status.icon,
"display_order": status.display_order,
"is_initial": status.is_initial,
"is_final": status.is_final,
"is_cancelled": status.is_cancelled,
"send_notification": status.send_notification,
"notification_template": status.notification_template
}
type_dict["statuses"].append(status_dict)
status_map[status.id] = status.status_id
# Add transitions
for status in app_type.statuses:
for transition in status.transitions_from:
type_dict["transitions"].append({
"from_status_id": status_map.get(transition.from_status_id),
"to_status_id": status_map.get(transition.to_status_id),
"name": transition.name,
"trigger_type": transition.trigger_type.value,
"trigger_config": transition.trigger_config,
"conditions": transition.conditions,
"actions": transition.actions,
"priority": transition.priority,
"is_active": transition.is_active
})
result.append(ApplicationTypeResponse(**type_dict))
return result
@router.get("/{type_id}", response_model=ApplicationTypeResponse)
async def get_application_type(
type_id: str,
current_user: Optional[User] = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get a specific application type"""
app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first()
if not app_type:
raise HTTPException(status_code=404, detail="Application type not found")
# Check access
if not app_type.is_public:
if not current_user:
raise HTTPException(status_code=403, detail="Access denied")
if not current_user.has_role("admin"):
if app_type.allowed_roles and not current_user.has_any_role(app_type.allowed_roles):
raise HTTPException(status_code=403, detail="Access denied")
# Build response
type_dict = {
"id": app_type.id,
"type_id": app_type.type_id,
"name": app_type.name,
"description": app_type.description,
"is_active": app_type.is_active,
"is_public": app_type.is_public,
"allowed_roles": app_type.allowed_roles or [],
"max_cost_positions": app_type.max_cost_positions,
"max_comparison_offers": app_type.max_comparison_offers,
"version": app_type.version,
"usage_count": app_type.usage_count,
"pdf_template_filename": app_type.pdf_template_filename,
"created_at": app_type.created_at.isoformat(),
"updated_at": app_type.updated_at.isoformat(),
"fields": [],
"statuses": [],
"transitions": []
}
# Add fields
for field in app_type.fields:
type_dict["fields"].append({
"field_id": field.field_id,
"field_type": field.field_type.value,
"name": field.name,
"label": field.label,
"description": field.description,
"field_order": field.field_order,
"is_required": field.is_required,
"is_readonly": field.is_readonly,
"is_hidden": field.is_hidden,
"options": field.options,
"default_value": field.default_value,
"validation_rules": field.validation_rules,
"display_conditions": field.display_conditions,
"placeholder": field.placeholder,
"section": field.section
})
# Add statuses and transitions
status_map = {}
for status in app_type.statuses:
status_dict = {
"status_id": status.status_id,
"name": status.name,
"description": status.description,
"is_editable": status.is_editable,
"color": status.color,
"icon": status.icon,
"display_order": status.display_order,
"is_initial": status.is_initial,
"is_final": status.is_final,
"is_cancelled": status.is_cancelled,
"send_notification": status.send_notification,
"notification_template": status.notification_template
}
type_dict["statuses"].append(status_dict)
status_map[status.id] = status.status_id
# Add transitions
for status in app_type.statuses:
for transition in status.transitions_from:
type_dict["transitions"].append({
"from_status_id": status_map.get(transition.from_status_id),
"to_status_id": status_map.get(transition.to_status_id),
"name": transition.name,
"trigger_type": transition.trigger_type.value,
"trigger_config": transition.trigger_config,
"conditions": transition.conditions,
"actions": transition.actions,
"priority": transition.priority,
"is_active": transition.is_active
})
return ApplicationTypeResponse(**type_dict)
@router.post("/", response_model=ApplicationTypeResponse)
async def create_application_type(
type_data: str = Form(...),
pdf_template: Optional[UploadFile] = File(None),
current_user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""Create a new application type (admin only)"""
try:
data = json.loads(type_data)
type_create = ApplicationTypeCreate(**data)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}")
# Check if type_id already exists
existing = db.query(ApplicationType).filter(ApplicationType.type_id == type_create.type_id).first()
if existing:
raise HTTPException(status_code=400, detail="Application type with this ID already exists")
# Create application type
app_type = ApplicationType(
type_id=type_create.type_id,
name=type_create.name,
description=type_create.description,
pdf_field_mapping=type_create.pdf_field_mapping,
is_active=type_create.is_active,
is_public=type_create.is_public,
allowed_roles=type_create.allowed_roles,
max_cost_positions=type_create.max_cost_positions,
max_comparison_offers=type_create.max_comparison_offers
)
# Handle PDF template upload
if pdf_template:
pdf_content = await pdf_template.read()
app_type.pdf_template = pdf_content
app_type.pdf_template_filename = pdf_template.filename
# Extract and validate PDF fields
try:
pdf_fields = extract_pdf_fields(pdf_content)
# Validate mapping
for pdf_field in type_create.pdf_field_mapping.keys():
if pdf_field not in pdf_fields:
raise ValueError(f"PDF field '{pdf_field}' not found in template")
except Exception as e:
raise HTTPException(status_code=400, detail=f"PDF validation failed: {str(e)}")
db.add(app_type)
db.flush()
# Create fields
for field_def in type_create.fields:
field = ApplicationField(
application_type_id=app_type.id,
field_id=field_def.field_id,
field_type=FieldType(field_def.field_type),
name=field_def.name,
label=field_def.label,
description=field_def.description,
field_order=field_def.field_order,
is_required=field_def.is_required,
is_readonly=field_def.is_readonly,
is_hidden=field_def.is_hidden,
options=field_def.options,
default_value=field_def.default_value,
validation_rules=field_def.validation_rules,
display_conditions=field_def.display_conditions,
placeholder=field_def.placeholder,
section=field_def.section
)
db.add(field)
# Create statuses
status_map = {}
for status_def in type_create.statuses:
status = ApplicationTypeStatus(
application_type_id=app_type.id,
status_id=status_def.status_id,
name=status_def.name,
description=status_def.description,
is_editable=status_def.is_editable,
color=status_def.color,
icon=status_def.icon,
display_order=status_def.display_order,
is_initial=status_def.is_initial,
is_final=status_def.is_final,
is_cancelled=status_def.is_cancelled,
send_notification=status_def.send_notification,
notification_template=status_def.notification_template
)
db.add(status)
db.flush()
status_map[status_def.status_id] = status
# Create transitions
for trans_def in type_create.transitions:
from_status = status_map.get(trans_def.from_status_id)
to_status = status_map.get(trans_def.to_status_id)
if not from_status or not to_status:
raise HTTPException(status_code=400, detail=f"Invalid status in transition: {trans_def.from_status_id} -> {trans_def.to_status_id}")
transition = StatusTransition(
from_status_id=from_status.id,
to_status_id=to_status.id,
name=trans_def.name,
trigger_type=TransitionTriggerType(trans_def.trigger_type),
trigger_config=trans_def.trigger_config,
conditions=trans_def.conditions,
actions=trans_def.actions,
priority=trans_def.priority,
is_active=trans_def.is_active
)
db.add(transition)
db.commit()
db.refresh(app_type)
# Return created type
return await get_application_type(app_type.type_id, current_user, db)
@router.put("/{type_id}", response_model=ApplicationTypeResponse)
async def update_application_type(
type_id: str,
update_data: ApplicationTypeUpdate,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""Update an application type (admin only)"""
app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first()
if not app_type:
raise HTTPException(status_code=404, detail="Application type not found")
# Update fields
if update_data.name is not None:
app_type.name = update_data.name
if update_data.description is not None:
app_type.description = update_data.description
if update_data.is_active is not None:
app_type.is_active = update_data.is_active
if update_data.is_public is not None:
app_type.is_public = update_data.is_public
if update_data.allowed_roles is not None:
app_type.allowed_roles = update_data.allowed_roles
if update_data.max_cost_positions is not None:
app_type.max_cost_positions = update_data.max_cost_positions
if update_data.max_comparison_offers is not None:
app_type.max_comparison_offers = update_data.max_comparison_offers
db.commit()
db.refresh(app_type)
return await get_application_type(app_type.type_id, current_user, db)
@router.post("/{type_id}/pdf-template")
async def upload_pdf_template(
type_id: str,
pdf_template: UploadFile = File(...),
current_user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""Upload or update PDF template for an application type"""
app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first()
if not app_type:
raise HTTPException(status_code=404, detail="Application type not found")
# Read and validate PDF
pdf_content = await pdf_template.read()
try:
pdf_fields = extract_pdf_fields(pdf_content)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid PDF template: {str(e)}")
# Update template
app_type.pdf_template = pdf_content
app_type.pdf_template_filename = pdf_template.filename
db.commit()
return {
"message": "PDF template uploaded successfully",
"filename": pdf_template.filename,
"fields": pdf_fields
}
@router.delete("/{type_id}")
async def delete_application_type(
type_id: str,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""Delete an application type (admin only)"""
app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first()
if not app_type:
raise HTTPException(status_code=404, detail="Application type not found")
# Check if type has been used
if app_type.usage_count > 0:
# Instead of deleting, deactivate it
app_type.is_active = False
db.commit()
return {"message": "Application type deactivated (has existing applications)"}
db.delete(app_type)
db.commit()
return {"message": "Application type deleted successfully"}
@router.post("/{type_id}/fields")
async def add_field_to_type(
type_id: str,
field: FieldDefinition,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""Add a field to an application type"""
app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first()
if not app_type:
raise HTTPException(status_code=404, detail="Application type not found")
# Check if field_id already exists
existing = db.query(ApplicationField).filter(
ApplicationField.application_type_id == app_type.id,
ApplicationField.field_id == field.field_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Field with this ID already exists")
new_field = ApplicationField(
application_type_id=app_type.id,
field_id=field.field_id,
field_type=FieldType(field.field_type),
name=field.name,
label=field.label,
description=field.description,
field_order=field.field_order,
is_required=field.is_required,
is_readonly=field.is_readonly,
is_hidden=field.is_hidden,
options=field.options,
default_value=field.default_value,
validation_rules=field.validation_rules,
display_conditions=field.display_conditions,
placeholder=field.placeholder,
section=field.section
)
db.add(new_field)
db.commit()
return {"message": "Field added successfully"}
@router.delete("/{type_id}/fields/{field_id}")
async def remove_field_from_type(
type_id: str,
field_id: str,
current_user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""Remove a field from an application type"""
app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first()
if not app_type:
raise HTTPException(status_code=404, detail="Application type not found")
field = db.query(ApplicationField).filter(
ApplicationField.application_type_id == app_type.id,
ApplicationField.field_id == field_id
).first()
if not field:
raise HTTPException(status_code=404, detail="Field not found")
db.delete(field)
db.commit()
return {"message": "Field removed successfully"}

View File

@ -0,0 +1,831 @@
"""
API routes for dynamic application management
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, and_, desc
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field, validator
from datetime import datetime, timedelta
import secrets
import hashlib
from ..config.database import get_db
from ..models.application_type import (
ApplicationType, ApplicationField, ApplicationTypeStatus,
DynamicApplication, ApplicationHistory, ApplicationAttachment,
ApplicationTransitionLog, ApplicationApproval, TransitionTriggerType
)
from ..models.user import User
from ..services.auth_service import get_current_user, get_optional_user
from ..services.notification_service import send_notification
from ..services.pdf_service import generate_pdf_for_application
from ..utils.validators import validate_field_value
router = APIRouter(prefix="/applications", tags=["Dynamic Applications"])
# Pydantic models
class CostPosition(BaseModel):
description: str
amount: float
category: Optional[str] = None
notes: Optional[str] = None
class ComparisonOffer(BaseModel):
vendor: str
description: str
amount: float
selected: bool = False
notes: Optional[str] = None
class ApplicationCreate(BaseModel):
application_type_id: str
title: str
field_data: Dict[str, Any] = Field(default_factory=dict)
cost_positions: Optional[List[CostPosition]] = None
comparison_offers: Optional[List[ComparisonOffer]] = None
@validator('cost_positions')
def validate_cost_positions(cls, v, values):
if v and len(v) > 100:
raise ValueError("Maximum 100 cost positions allowed")
return v
@validator('comparison_offers')
def validate_comparison_offers(cls, v, values):
if v and len(v) > 100:
raise ValueError("Maximum 100 comparison offers allowed")
return v
class ApplicationUpdate(BaseModel):
title: Optional[str] = None
field_data: Optional[Dict[str, Any]] = None
cost_positions: Optional[List[CostPosition]] = None
comparison_offers: Optional[List[ComparisonOffer]] = None
class ApplicationResponse(BaseModel):
id: int
application_id: str
application_type_id: int
type_name: str
email: str
status_id: str
status_name: str
title: str
first_name: Optional[str]
last_name: Optional[str]
total_amount: float
field_data: Dict[str, Any]
cost_positions: List[Dict[str, Any]]
comparison_offers: List[Dict[str, Any]]
submitted_at: Optional[datetime]
status_changed_at: Optional[datetime]
created_at: datetime
updated_at: datetime
can_edit: bool
available_actions: List[str]
class Config:
from_attributes = True
class ApplicationListResponse(BaseModel):
id: int
application_id: str
type_name: str
title: str
email: str
status_id: str
status_name: str
total_amount: float
submitted_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class StatusTransitionRequest(BaseModel):
new_status_id: str
comment: Optional[str] = None
trigger_data: Optional[Dict[str, Any]] = None
class ApprovalRequest(BaseModel):
decision: str # approve, reject, abstain
comment: Optional[str] = None
@router.get("/", response_model=List[ApplicationListResponse])
async def list_applications(
type_id: Optional[str] = None,
status_id: Optional[str] = None,
email: Optional[str] = None,
search: Optional[str] = None,
submitted_after: Optional[datetime] = None,
submitted_before: Optional[datetime] = None,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db)
):
"""List applications with filtering"""
query = db.query(DynamicApplication).join(ApplicationType)
# Filter by type
if type_id:
query = query.filter(ApplicationType.type_id == type_id)
# Filter by status
if status_id:
query = query.filter(DynamicApplication.status_id == status_id)
# Filter by email (for users to see their own applications)
if email:
query = query.filter(DynamicApplication.email == email)
elif current_user and not current_user.has_role("admin"):
# Non-admin users only see their own applications
query = query.filter(DynamicApplication.email == current_user.email)
# Search
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
DynamicApplication.title.ilike(search_term),
DynamicApplication.email.ilike(search_term),
DynamicApplication.search_text.ilike(search_term)
)
)
# Date filters
if submitted_after:
query = query.filter(DynamicApplication.submitted_at >= submitted_after)
if submitted_before:
query = query.filter(DynamicApplication.submitted_at <= submitted_before)
# Order and paginate
query = query.order_by(desc(DynamicApplication.created_at))
applications = query.offset(offset).limit(limit).all()
# Build response
result = []
for app in applications:
# Get status name
status = db.query(ApplicationTypeStatus).filter(
ApplicationTypeStatus.application_type_id == app.application_type_id,
ApplicationTypeStatus.status_id == app.status_id
).first()
result.append({
"id": app.id,
"application_id": app.application_id,
"type_name": app.application_type.name,
"title": app.title,
"email": app.email,
"status_id": app.status_id,
"status_name": status.name if status else app.status_id,
"total_amount": app.total_amount,
"submitted_at": app.submitted_at,
"created_at": app.created_at
})
return result
@router.get("/{application_id}", response_model=ApplicationResponse)
async def get_application(
application_id: str,
access_key: Optional[str] = None,
current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db)
):
"""Get application details"""
app = db.query(DynamicApplication).filter(
DynamicApplication.application_id == application_id
).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Check access
has_access = False
if current_user:
if current_user.has_role("admin"):
has_access = True
elif app.email == current_user.email:
has_access = True
elif access_key:
# Verify access key
key_hash = hashlib.sha256(access_key.encode()).hexdigest()
if app.application_key == key_hash:
has_access = True
if not has_access:
raise HTTPException(status_code=403, detail="Access denied")
# Get status details
status = db.query(ApplicationTypeStatus).filter(
ApplicationTypeStatus.application_type_id == app.application_type_id,
ApplicationTypeStatus.status_id == app.status_id
).first()
# Determine if editable
can_edit = False
if status and status.is_editable:
if current_user and (current_user.has_role("admin") or app.email == current_user.email):
can_edit = True
elif access_key:
can_edit = True
# Get available actions
available_actions = []
if current_user:
# Check for available transitions
transitions = db.query(StatusTransition).filter(
StatusTransition.from_status_id == status.id,
StatusTransition.is_active == True
).all()
for trans in transitions:
if trans.trigger_type == TransitionTriggerType.APPLICANT_ACTION:
if app.email == current_user.email:
available_actions.append(trans.name)
elif trans.trigger_type == TransitionTriggerType.USER_APPROVAL:
config = trans.trigger_config or {}
required_role = config.get("role")
if required_role and current_user.has_role(required_role):
available_actions.append(trans.name)
return ApplicationResponse(
id=app.id,
application_id=app.application_id,
application_type_id=app.application_type_id,
type_name=app.application_type.name,
email=app.email,
status_id=app.status_id,
status_name=status.name if status else app.status_id,
title=app.title,
first_name=app.first_name,
last_name=app.last_name,
total_amount=app.total_amount,
field_data=app.field_data or {},
cost_positions=app.cost_positions or [],
comparison_offers=app.comparison_offers or [],
submitted_at=app.submitted_at,
status_changed_at=app.status_changed_at,
created_at=app.created_at,
updated_at=app.updated_at,
can_edit=can_edit,
available_actions=available_actions
)
@router.post("/", response_model=Dict[str, Any])
async def create_application(
application_data: ApplicationCreate,
background_tasks: BackgroundTasks,
current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db)
):
"""Create a new application"""
# Get application type
app_type = db.query(ApplicationType).filter(
ApplicationType.type_id == application_data.application_type_id,
ApplicationType.is_active == True
).first()
if not app_type:
raise HTTPException(status_code=404, detail="Application type not found or inactive")
# Check access to type
if not app_type.is_public:
if not current_user:
raise HTTPException(status_code=403, detail="Authentication required")
if app_type.allowed_roles and not current_user.has_any_role(app_type.allowed_roles):
raise HTTPException(status_code=403, detail="Not authorized for this application type")
# Validate fields
for field in app_type.fields:
if field.is_required and field.field_id not in application_data.field_data:
raise HTTPException(status_code=400, detail=f"Required field missing: {field.name}")
if field.field_id in application_data.field_data:
value = application_data.field_data[field.field_id]
try:
validate_field_value(value, field)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Get initial status
initial_status = db.query(ApplicationTypeStatus).filter(
ApplicationTypeStatus.application_type_id == app_type.id,
ApplicationTypeStatus.is_initial == True
).first()
if not initial_status:
# Fallback to first status
initial_status = db.query(ApplicationTypeStatus).filter(
ApplicationTypeStatus.application_type_id == app_type.id
).order_by(ApplicationTypeStatus.display_order).first()
if not initial_status:
raise HTTPException(status_code=500, detail="No status defined for application type")
# Generate application ID and access key
app_id = secrets.token_urlsafe(16)
access_key = secrets.token_urlsafe(32)
key_hash = hashlib.sha256(access_key.encode()).hexdigest()
# Create application
application = DynamicApplication(
application_id=app_id,
application_key=key_hash,
application_type_id=app_type.id,
user_id=current_user.id if current_user else None,
email=current_user.email if current_user else application_data.field_data.get("email", ""),
status_id=initial_status.status_id,
title=application_data.title,
first_name=current_user.given_name if current_user else application_data.field_data.get("first_name"),
last_name=current_user.family_name if current_user else application_data.field_data.get("last_name"),
field_data=application_data.field_data,
cost_positions=[cp.dict() for cp in application_data.cost_positions] if application_data.cost_positions else [],
comparison_offers=[co.dict() for co in application_data.comparison_offers] if application_data.comparison_offers else []
)
# Calculate total amount
application.calculate_total_amount()
# Update search text
application.update_search_text()
db.add(application)
db.flush()
# Create history entry
history = ApplicationHistory(
application_id=application.id,
user_id=current_user.id if current_user else None,
action="created",
comment="Application created"
)
db.add(history)
# Update usage count
app_type.usage_count += 1
db.commit()
db.refresh(application)
# Send notification
if initial_status.send_notification:
background_tasks.add_task(
send_notification,
application.email,
"Application Created",
initial_status.notification_template or f"Your application {app_id} has been created."
)
return {
"application_id": app_id,
"access_key": access_key,
"access_url": f"/applications/{app_id}?key={access_key}",
"status": initial_status.status_id
}
@router.put("/{application_id}", response_model=ApplicationResponse)
async def update_application(
application_id: str,
update_data: ApplicationUpdate,
access_key: Optional[str] = None,
current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db)
):
"""Update an application"""
app = db.query(DynamicApplication).filter(
DynamicApplication.application_id == application_id
).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Check access and editability
has_access = False
if current_user:
if current_user.has_role("admin"):
has_access = True
elif app.email == current_user.email:
has_access = True
elif access_key:
key_hash = hashlib.sha256(access_key.encode()).hexdigest()
if app.application_key == key_hash:
has_access = True
if not has_access:
raise HTTPException(status_code=403, detail="Access denied")
# Check if status allows editing
status = db.query(ApplicationTypeStatus).filter(
ApplicationTypeStatus.application_type_id == app.application_type_id,
ApplicationTypeStatus.status_id == app.status_id
).first()
if not status or not status.is_editable:
raise HTTPException(status_code=400, detail="Application cannot be edited in current status")
# Track changes
changes = {}
# Update fields
if update_data.title is not None:
changes["title"] = {"old": app.title, "new": update_data.title}
app.title = update_data.title
if update_data.field_data is not None:
# Validate new field data
app_type = app.application_type
for field in app_type.fields:
if field.is_required and field.field_id not in update_data.field_data:
raise HTTPException(status_code=400, detail=f"Required field missing: {field.name}")
if field.field_id in update_data.field_data:
value = update_data.field_data[field.field_id]
try:
validate_field_value(value, field)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
changes["field_data"] = {"old": app.field_data, "new": update_data.field_data}
app.field_data = update_data.field_data
if update_data.cost_positions is not None:
cost_data = [cp.dict() for cp in update_data.cost_positions]
changes["cost_positions"] = {"old": app.cost_positions, "new": cost_data}
app.cost_positions = cost_data
app.calculate_total_amount()
if update_data.comparison_offers is not None:
offer_data = [co.dict() for co in update_data.comparison_offers]
changes["comparison_offers"] = {"old": app.comparison_offers, "new": offer_data}
app.comparison_offers = offer_data
# Update search text
app.update_search_text()
# Create history entry
if changes:
history = ApplicationHistory(
application_id=app.id,
user_id=current_user.id if current_user else None,
action="updated",
field_changes=changes,
comment="Application updated"
)
db.add(history)
db.commit()
db.refresh(app)
return await get_application(application_id, access_key, current_user, db)
@router.post("/{application_id}/submit")
async def submit_application(
application_id: str,
access_key: Optional[str] = None,
background_tasks: BackgroundTasks = None,
current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db)
):
"""Submit an application for review"""
app = db.query(DynamicApplication).filter(
DynamicApplication.application_id == application_id
).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Check access
has_access = False
if current_user and app.email == current_user.email:
has_access = True
elif access_key:
key_hash = hashlib.sha256(access_key.encode()).hexdigest()
if app.application_key == key_hash:
has_access = True
if not has_access:
raise HTTPException(status_code=403, detail="Access denied")
# Check if already submitted
if app.submitted_at:
raise HTTPException(status_code=400, detail="Application already submitted")
# Find submit transition
current_status = db.query(ApplicationTypeStatus).filter(
ApplicationTypeStatus.application_type_id == app.application_type_id,
ApplicationTypeStatus.status_id == app.status_id
).first()
if not current_status:
raise HTTPException(status_code=500, detail="Current status not found")
# Find transition for submit action
from ..models.application_type import StatusTransition
transition = db.query(StatusTransition).filter(
StatusTransition.from_status_id == current_status.id,
StatusTransition.trigger_type == TransitionTriggerType.APPLICANT_ACTION,
StatusTransition.is_active == True
).first()
if not transition:
raise HTTPException(status_code=400, detail="Submit action not available in current status")
# Get target status
target_status = db.query(ApplicationTypeStatus).filter(
ApplicationTypeStatus.id == transition.to_status_id
).first()
if not target_status:
raise HTTPException(status_code=500, detail="Target status not found")
# Update application
app.submitted_at = datetime.utcnow()
app.status_id = target_status.status_id
app.status_changed_at = datetime.utcnow()
# Log transition
transition_log = ApplicationTransitionLog(
application_id=app.id,
from_status=current_status.status_id,
to_status=target_status.status_id,
transition_name=transition.name,
trigger_type=TransitionTriggerType.APPLICANT_ACTION.value,
triggered_by=current_user.id if current_user else None
)
db.add(transition_log)
# Create history entry
history = ApplicationHistory(
application_id=app.id,
user_id=current_user.id if current_user else None,
action="submitted",
comment="Application submitted for review"
)
db.add(history)
db.commit()
# Send notification
if target_status.send_notification and background_tasks:
background_tasks.add_task(
send_notification,
app.email,
"Application Submitted",
target_status.notification_template or f"Your application {app.application_id} has been submitted."
)
return {
"message": "Application submitted successfully",
"new_status": target_status.status_id
}
@router.post("/{application_id}/transition")
async def transition_application_status(
application_id: str,
transition_request: StatusTransitionRequest,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Transition application to a new status"""
app = db.query(DynamicApplication).filter(
DynamicApplication.application_id == application_id
).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Check admin access
if not current_user.has_role("admin"):
raise HTTPException(status_code=403, detail="Admin access required")
# Get current and target status
current_status = db.query(ApplicationTypeStatus).filter(
ApplicationTypeStatus.application_type_id == app.application_type_id,
ApplicationTypeStatus.status_id == app.status_id
).first()
target_status = db.query(ApplicationTypeStatus).filter(
ApplicationTypeStatus.application_type_id == app.application_type_id,
ApplicationTypeStatus.status_id == transition_request.new_status_id
).first()
if not current_status or not target_status:
raise HTTPException(status_code=400, detail="Invalid status")
# Check if transition is valid
from ..models.application_type import StatusTransition
transition = db.query(StatusTransition).filter(
StatusTransition.from_status_id == current_status.id,
StatusTransition.to_status_id == target_status.id,
StatusTransition.is_active == True
).first()
if not transition:
raise HTTPException(status_code=400, detail="Invalid status transition")
# Update application
app.status_id = target_status.status_id
app.status_changed_at = datetime.utcnow()
if target_status.is_final:
app.completed_at = datetime.utcnow()
# Log transition
transition_log = ApplicationTransitionLog(
application_id=app.id,
from_status=current_status.status_id,
to_status=target_status.status_id,
transition_name=transition.name,
trigger_type=transition.trigger_type.value,
triggered_by=current_user.id,
trigger_data=transition_request.trigger_data
)
db.add(transition_log)
# Create history entry
history = ApplicationHistory(
application_id=app.id,
user_id=current_user.id,
action="status_changed",
comment=transition_request.comment or f"Status changed from {current_status.name} to {target_status.name}"
)
db.add(history)
db.commit()
# Send notification
if target_status.send_notification:
background_tasks.add_task(
send_notification,
app.email,
f"Application Status Changed: {target_status.name}",
target_status.notification_template or f"Your application {app.application_id} status has been updated to {target_status.name}."
)
return {
"message": "Status changed successfully",
"new_status": target_status.status_id,
"new_status_name": target_status.name
}
@router.post("/{application_id}/approve")
async def approve_application(
application_id: str,
approval: ApprovalRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Approve or reject an application"""
app = db.query(DynamicApplication).filter(
DynamicApplication.application_id == application_id
).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Determine user's role for this approval
approval_role = None
if current_user.has_role("budget_reviewer"):
approval_role = "budget_reviewer"
elif current_user.has_role("finance_reviewer"):
approval_role = "finance_reviewer"
elif current_user.has_role("asta_member"):
approval_role = "asta_member"
else:
raise HTTPException(status_code=403, detail="No approval permission")
# Check if already approved by this user
existing = db.query(ApplicationApproval).filter(
ApplicationApproval.application_id == app.id,
ApplicationApproval.user_id == current_user.id,
ApplicationApproval.role == approval_role
).first()
if existing:
# Update existing approval
existing.decision = approval.decision
existing.comment = approval.comment
existing.updated_at = datetime.utcnow()
else:
# Create new approval
new_approval = ApplicationApproval(
application_id=app.id,
user_id=current_user.id,
role=approval_role,
decision=approval.decision,
comment=approval.comment,
status_at_approval=app.status_id
)
db.add(new_approval)
# Create history entry
history = ApplicationHistory(
application_id=app.id,
user_id=current_user.id,
action=f"{approval_role}_{approval.decision}",
comment=approval.comment or f"{approval_role} {approval.decision}"
)
db.add(history)
# Check if this triggers a status transition
# (This would be implemented based on transition rules)
db.commit()
return {
"message": f"Approval recorded: {approval.decision}",
"role": approval_role,
"decision": approval.decision
}
@router.get("/{application_id}/history")
async def get_application_history(
application_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get application history"""
app = db.query(DynamicApplication).filter(
DynamicApplication.application_id == application_id
).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Check access
if not current_user.has_role("admin") and app.email != current_user.email:
raise HTTPException(status_code=403, detail="Access denied")
history = db.query(ApplicationHistory).filter(
ApplicationHistory.application_id == app.id
).order_by(desc(ApplicationHistory.created_at)).all()
result = []
for entry in history:
result.append({
"id": entry.id,
"action": entry.action,
"comment": entry.comment,
"field_changes": entry.field_changes,
"user_id": entry.user_id,
"created_at": entry.created_at
})
return result
@router.post("/{application_id}/generate-pdf")
async def generate_application_pdf(
application_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Generate PDF for an application"""
app = db.query(DynamicApplication).filter(
DynamicApplication.application_id == application_id
).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Check access
if not current_user.has_role("admin") and app.email != current_user.email:
raise HTTPException(status_code=403, detail="Access denied")
# Generate PDF
try:
pdf_path = generate_pdf_for_application(app, db)
app.pdf_generated = True
app.pdf_generated_at = datetime.utcnow()
app.pdf_file_path = pdf_path
db.commit()
return {
"message": "PDF generated successfully",
"pdf_path": pdf_path
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}")

View File

@ -0,0 +1,11 @@
"""API middleware modules."""
from .error_handler import ErrorHandlerMiddleware
from .logging import LoggingMiddleware
from .rate_limit import RateLimitMiddleware
__all__ = [
"ErrorHandlerMiddleware",
"LoggingMiddleware",
"RateLimitMiddleware",
]

View File

@ -0,0 +1,220 @@
"""Error handling middleware for API exceptions."""
import logging
import traceback
from typing import Optional
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from fastapi import status
from fastapi.exceptions import RequestValidationError, HTTPException
from pydantic import ValidationError
from sqlalchemy.exc import SQLAlchemyError
logger = logging.getLogger(__name__)
class ErrorHandlerMiddleware(BaseHTTPMiddleware):
"""Middleware for handling API errors and exceptions."""
def __init__(self, app):
"""
Initialize error handler middleware.
Args:
app: The FastAPI application
"""
super().__init__(app)
def _get_request_id(self, request: Request) -> str:
"""
Get request ID from request state.
Args:
request: The incoming request
Returns:
Request ID or 'unknown'
"""
return getattr(request.state, 'request_id', 'unknown')
def _format_error_response(
self,
request: Request,
status_code: int,
error_type: str,
message: str,
details: Optional[dict] = None
) -> JSONResponse:
"""
Format error response.
Args:
request: The incoming request
status_code: HTTP status code
error_type: Type of error
message: Error message
details: Additional error details
Returns:
JSONResponse with error information
"""
error_response = {
"error": {
"type": error_type,
"message": message,
"path": request.url.path,
"method": request.method,
"request_id": self._get_request_id(request)
}
}
if details:
error_response["error"]["details"] = details
return JSONResponse(
status_code=status_code,
content=error_response,
headers={
"X-Request-ID": self._get_request_id(request),
"X-Error-Type": error_type
}
)
async def dispatch(self, request: Request, call_next):
"""
Process the request and handle any errors.
Args:
request: The incoming request
call_next: The next middleware or endpoint
Returns:
The response
"""
try:
response = await call_next(request)
return response
except HTTPException as e:
# Handle FastAPI HTTP exceptions
logger.warning(
f"HTTP exception: {e.status_code} - {e.detail}",
extra={
"request_id": self._get_request_id(request),
"status_code": e.status_code,
"path": request.url.path
}
)
return self._format_error_response(
request=request,
status_code=e.status_code,
error_type="http_error",
message=str(e.detail),
details={"status_code": e.status_code}
)
except RequestValidationError as e:
# Handle validation errors
logger.warning(
f"Validation error: {str(e)}",
extra={
"request_id": self._get_request_id(request),
"path": request.url.path,
"errors": e.errors()
}
)
return self._format_error_response(
request=request,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
error_type="validation_error",
message="Request validation failed",
details={"validation_errors": e.errors()}
)
except ValidationError as e:
# Handle Pydantic validation errors
logger.warning(
f"Pydantic validation error: {str(e)}",
extra={
"request_id": self._get_request_id(request),
"path": request.url.path,
"errors": e.errors()
}
)
return self._format_error_response(
request=request,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
error_type="validation_error",
message="Data validation failed",
details={"validation_errors": e.errors()}
)
except SQLAlchemyError as e:
# Handle database errors
logger.error(
f"Database error: {str(e)}",
extra={
"request_id": self._get_request_id(request),
"path": request.url.path,
"error": str(e)
},
exc_info=True
)
return self._format_error_response(
request=request,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
error_type="database_error",
message="A database error occurred",
details={"error": "Database operation failed"}
)
except ValueError as e:
# Handle value errors
logger.error(
f"Value error: {str(e)}",
extra={
"request_id": self._get_request_id(request),
"path": request.url.path,
"error": str(e)
}
)
return self._format_error_response(
request=request,
status_code=status.HTTP_400_BAD_REQUEST,
error_type="value_error",
message=str(e)
)
except Exception as e:
# Handle all other exceptions
error_id = self._get_request_id(request)
logger.error(
f"Unexpected error: {str(e)}",
extra={
"request_id": error_id,
"path": request.url.path,
"error": str(e),
"traceback": traceback.format_exc()
},
exc_info=True
)
# Determine if we should show detailed error (dev mode)
show_details = False # Set to True in development
error_message = "An unexpected error occurred"
error_details = {"error_id": error_id}
if show_details:
error_message = str(e)
error_details["exception"] = type(e).__name__
error_details["traceback"] = traceback.format_exc().split('\n')
return self._format_error_response(
request=request,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
error_type="internal_error",
message=error_message,
details=error_details
)

View File

@ -0,0 +1,206 @@
"""Logging middleware for API request/response tracking."""
import time
import json
import logging
from typing import Optional
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
import uuid
logger = logging.getLogger(__name__)
class LoggingMiddleware(BaseHTTPMiddleware):
"""Middleware for logging API requests and responses."""
def __init__(self, app):
"""
Initialize logging middleware.
Args:
app: The FastAPI application
"""
super().__init__(app)
self.skip_paths = {
"/health",
"/ready",
"/docs",
"/redoc",
"/openapi.json",
"/favicon.ico"
}
def _get_client_info(self, request: Request) -> dict:
"""
Extract client information from request.
Args:
request: The incoming request
Returns:
Dictionary containing client information
"""
client_info = {}
# Get client IP
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
client_info["ip"] = forwarded_for.split(",")[0].strip()
elif real_ip := request.headers.get("X-Real-IP"):
client_info["ip"] = real_ip
elif request.client:
client_info["ip"] = request.client.host
else:
client_info["ip"] = "unknown"
# Get user agent
client_info["user_agent"] = request.headers.get("User-Agent", "unknown")
return client_info
def _should_log_body(self, content_type: Optional[str]) -> bool:
"""
Determine if request/response body should be logged.
Args:
content_type: The content type header value
Returns:
Boolean indicating if body should be logged
"""
if not content_type:
return False
loggable_types = [
"application/json",
"application/x-www-form-urlencoded",
"text/plain",
"text/html",
"text/xml",
"application/xml"
]
return any(t in content_type.lower() for t in loggable_types)
async def _get_request_body(self, request: Request) -> Optional[str]:
"""
Safely get request body for logging.
Args:
request: The incoming request
Returns:
Request body as string or None
"""
try:
# Check content type
content_type = request.headers.get("Content-Type", "")
if not self._should_log_body(content_type):
return None
# Don't log large bodies
content_length = request.headers.get("Content-Length")
if content_length and int(content_length) > 10000: # 10KB limit
return "[Body too large to log]"
# Get body
body = await request.body()
if body:
if "application/json" in content_type:
# Try to parse as JSON for better formatting
try:
return json.dumps(json.loads(body), indent=2)
except:
return body.decode("utf-8", errors="ignore")
else:
return body.decode("utf-8", errors="ignore")
return None
except Exception as e:
logger.debug(f"Could not get request body: {e}")
return None
async def dispatch(self, request: Request, call_next):
"""
Process the request and log details.
Args:
request: The incoming request
call_next: The next middleware or endpoint
Returns:
The response
"""
# Skip logging for certain paths
if request.url.path in self.skip_paths:
return await call_next(request)
# Generate request ID
request_id = str(uuid.uuid4())[:8]
# Start timing
start_time = time.time()
# Get client info
client_info = self._get_client_info(request)
# Log request
logger.info(
f"[{request_id}] Request: {request.method} {request.url.path}",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"query_params": dict(request.query_params),
"client": client_info
}
)
# Store request ID in request state for use in endpoints
request.state.request_id = request_id
# Process request
try:
response = await call_next(request)
except Exception as e:
# Log exception
process_time = time.time() - start_time
logger.error(
f"[{request_id}] Request failed after {process_time:.3f}s: {str(e)}",
extra={
"request_id": request_id,
"process_time": process_time,
"error": str(e)
},
exc_info=True
)
raise
# Calculate process time
process_time = time.time() - start_time
# Add request ID to response headers
response.headers["X-Request-ID"] = request_id
response.headers["X-Process-Time"] = f"{process_time:.3f}"
# Log response
log_level = logging.INFO
if response.status_code >= 500:
log_level = logging.ERROR
elif response.status_code >= 400:
log_level = logging.WARNING
logger.log(
log_level,
f"[{request_id}] Response: {response.status_code} in {process_time:.3f}s",
extra={
"request_id": request_id,
"status_code": response.status_code,
"process_time": process_time
}
)
return response

View File

@ -0,0 +1,165 @@
"""Rate limiting middleware for API endpoints."""
import time
from typing import Dict, Optional
from collections import defaultdict
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from fastapi import status
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Middleware for rate limiting API requests."""
def __init__(self, app, settings=None):
"""
Initialize rate limit middleware.
Args:
app: The FastAPI application
settings: Rate limit settings object
"""
super().__init__(app)
self.settings = settings
# Store request counts per IP
self.request_counts: Dict[str, Dict[str, float]] = defaultdict(dict)
# Default settings if not provided
self.requests_per_minute = 60
self.requests_per_hour = 1000
if settings:
self.requests_per_minute = getattr(settings, 'requests_per_minute', 60)
self.requests_per_hour = getattr(settings, 'requests_per_hour', 1000)
def _get_client_ip(self, request: Request) -> str:
"""
Get the client IP address from the request.
Args:
request: The incoming request
Returns:
The client IP address
"""
# Try to get real IP from headers (for proxy scenarios)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
return forwarded_for.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
# Fallback to client host
if request.client:
return request.client.host
return "unknown"
def _is_rate_limited(self, client_ip: str) -> tuple[bool, Optional[str]]:
"""
Check if the client has exceeded rate limits.
Args:
client_ip: The client IP address
Returns:
Tuple of (is_limited, reason)
"""
current_time = time.time()
client_data = self.request_counts[client_ip]
# Clean up old entries
minute_ago = current_time - 60
hour_ago = current_time - 3600
# Remove entries older than an hour
client_data = {
timestamp: count
for timestamp, count in client_data.items()
if float(timestamp) > hour_ago
}
# Count requests in the last minute
minute_requests = sum(
count for timestamp, count in client_data.items()
if float(timestamp) > minute_ago
)
# Count requests in the last hour
hour_requests = sum(client_data.values())
# Check minute limit
if minute_requests >= self.requests_per_minute:
return True, f"Rate limit exceeded: {self.requests_per_minute} requests per minute"
# Check hour limit
if hour_requests >= self.requests_per_hour:
return True, f"Rate limit exceeded: {self.requests_per_hour} requests per hour"
# Update request count
timestamp_key = str(current_time)
client_data[timestamp_key] = client_data.get(timestamp_key, 0) + 1
self.request_counts[client_ip] = client_data
return False, None
async def dispatch(self, request: Request, call_next):
"""
Process the request and apply rate limiting.
Args:
request: The incoming request
call_next: The next middleware or endpoint
Returns:
The response
"""
# Skip rate limiting for health check endpoints
if request.url.path in ["/health", "/ready", "/docs", "/redoc", "/openapi.json"]:
return await call_next(request)
# Get client IP
client_ip = self._get_client_ip(request)
# Check rate limit
is_limited, reason = self._is_rate_limited(client_ip)
if is_limited:
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={
"detail": reason,
"type": "rate_limit_exceeded"
},
headers={
"Retry-After": "60", # Suggest retry after 60 seconds
"X-RateLimit-Limit": str(self.requests_per_minute),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(int(time.time()) + 60)
}
)
# Process the request
response = await call_next(request)
# Add rate limit headers to successful responses
if hasattr(self, 'request_counts') and client_ip in self.request_counts:
current_time = time.time()
minute_ago = current_time - 60
minute_requests = sum(
count for timestamp, count in self.request_counts[client_ip].items()
if float(timestamp) > minute_ago
)
remaining = max(0, self.requests_per_minute - minute_requests)
response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(int(current_time) + 60)
return response

View File

@ -0,0 +1,80 @@
"""
API Routes Module
This module exports all available API routers.
"""
from fastapi import APIRouter
# Create placeholder routers for now
application_router = APIRouter(tags=["applications"])
attachment_router = APIRouter(tags=["attachments"])
pdf_router = APIRouter(tags=["pdf"])
auth_router = APIRouter(tags=["authentication"])
health_router = APIRouter(tags=["health"])
# Import actual routes when available
try:
from ..v1.auth import router as auth_v1_router
auth_router = auth_v1_router
except ImportError:
pass
try:
from .applications import router as app_router
application_router = app_router
except ImportError:
pass
try:
from .attachments import router as attach_router
attachment_router = attach_router
except ImportError:
pass
try:
from .pdf import router as pdf_route
pdf_router = pdf_route
except ImportError:
pass
try:
from .health import router as health_route
health_router = health_route
except ImportError:
pass
# Health check endpoints
@health_router.get("/")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "api"}
@health_router.get("/ready")
async def readiness_check():
"""Readiness check endpoint"""
return {"status": "ready", "service": "api"}
# Placeholder endpoints for missing routes
@application_router.get("/")
async def list_applications():
"""List applications"""
return {"applications": []}
@attachment_router.get("/")
async def list_attachments():
"""List attachments"""
return {"attachments": []}
@pdf_router.get("/")
async def pdf_info():
"""PDF service info"""
return {"service": "pdf", "version": "1.0.0"}
__all__ = [
"application_router",
"attachment_router",
"pdf_router",
"auth_router",
"health_router"
]

View File

@ -0,0 +1,245 @@
"""
Database Configuration Module
This module provides database configuration and connection utilities.
"""
import os
from typing import Optional, Generator
from urllib.parse import quote_plus
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.pool import QueuePool
from .settings import get_settings
def get_database_url() -> str:
"""
Get the database connection URL.
Returns:
Database connection URL string
"""
# Always prefer environment variables for database connection
# This ensures Docker environment settings take precedence
db_type = os.getenv("DB_TYPE", "mysql")
if db_type == "sqlite":
db_path = os.getenv("SQLITE_PATH", "./app.db")
return f"sqlite:///{db_path}"
# MySQL/MariaDB connection
host = os.getenv("MYSQL_HOST", "localhost")
port = os.getenv("MYSQL_PORT", "3306")
database = os.getenv("MYSQL_DB", "stupa")
user = os.getenv("MYSQL_USER", "stupa")
password = os.getenv("MYSQL_PASSWORD", "secret")
# URL encode the password to handle special characters
password_encoded = quote_plus(password)
return f"mysql+pymysql://{user}:{password_encoded}@{host}:{port}/{database}?charset=utf8mb4"
def get_engine(url: Optional[str] = None):
"""
Create and return a SQLAlchemy engine.
Args:
url: Optional database URL. If not provided, will use get_database_url()
Returns:
SQLAlchemy Engine instance
"""
if url is None:
url = get_database_url()
# Engine configuration
engine_config = {
"pool_size": int(os.getenv("DB_POOL_SIZE", "10")),
"max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "20")),
"pool_pre_ping": os.getenv("DB_POOL_PRE_PING", "true").lower() == "true",
"pool_recycle": int(os.getenv("DB_POOL_RECYCLE", "3600")),
"echo": os.getenv("DB_ECHO", "false").lower() == "true",
}
# Create engine with connection pooling
engine = create_engine(
url,
poolclass=QueuePool,
**engine_config
)
return engine
# Global engine and session factory
_engine = None
_session_factory = None
def get_session_factory() -> sessionmaker:
"""
Get or create a session factory.
Returns:
SQLAlchemy sessionmaker instance
"""
global _session_factory, _engine
if _session_factory is None:
if _engine is None:
_engine = get_engine()
_session_factory = sessionmaker(
bind=_engine,
autocommit=False,
autoflush=False,
expire_on_commit=False
)
return _session_factory
def get_db() -> Generator[Session, None, None]:
"""
Dependency injection for FastAPI to get database session.
Yields:
Database session
"""
session_factory = get_session_factory()
db = session_factory()
try:
yield db
finally:
db.close()
def init_database():
"""
Initialize the database (create tables).
This should be called on application startup.
"""
from models.base import Base
from models.application_type import (
ApplicationType,
ApplicationField,
ApplicationTypeStatus,
StatusTransition,
DynamicApplication,
ApplicationHistory,
ApplicationAttachment,
ApplicationTransitionLog,
ApplicationApproval
)
from models.user import User, Role, user_roles
engine = get_engine()
# Create all tables
Base.metadata.create_all(bind=engine)
# Initialize default data if needed
init_default_data(engine)
def init_default_data(engine):
"""
Initialize default data in the database.
Args:
engine: SQLAlchemy engine instance
"""
from sqlalchemy.orm import Session
from models.user import Role
with Session(engine) as session:
# Check if default roles exist
admin_role = session.query(Role).filter_by(name="admin").first()
if not admin_role:
# Create default roles
default_roles = [
Role(
name="admin",
display_name="Administrator",
description="Full system access",
is_admin=True,
is_system=True,
permissions=["*"]
),
Role(
name="budget_reviewer",
display_name="Haushaltsbeauftragte",
description="Budget review permissions",
can_review_budget=True,
is_system=True,
permissions=["applications.review", "applications.view"]
),
Role(
name="finance_reviewer",
display_name="Finanzreferent",
description="Finance review permissions",
can_review_finance=True,
is_system=True,
permissions=["applications.review", "applications.view", "applications.finance"]
),
Role(
name="asta_member",
display_name="AStA Member",
description="AStA voting member",
can_vote=True,
is_system=True,
permissions=["applications.vote", "applications.view"]
),
Role(
name="applicant",
display_name="Applicant",
description="Can create and manage own applications",
is_system=True,
permissions=["applications.create", "applications.own.view", "applications.own.edit"]
)
]
for role in default_roles:
session.add(role)
session.commit()
print("Default roles created successfully")
def drop_database():
"""
Drop all database tables.
WARNING: This will delete all data!
"""
from ..models.base import Base
engine = get_engine()
Base.metadata.drop_all(bind=engine)
print("All database tables dropped")
def reset_database():
"""
Reset the database (drop and recreate all tables).
WARNING: This will delete all data!
"""
drop_database()
init_database()
print("Database reset complete")
# Export for backwards compatibility
__all__ = [
"get_database_url",
"get_engine",
"get_session_factory",
"get_db",
"init_database",
"drop_database",
"reset_database"
]

View File

@ -26,6 +26,18 @@ class DatabaseSettings(BaseSettings):
pool_pre_ping: bool = Field(default=True, env="DB_POOL_PRE_PING")
echo: bool = Field(default=False, env="DB_ECHO")
def __init__(self, **kwargs):
"""Initialize DatabaseSettings and log environment variables"""
import os
import logging
logger = logging.getLogger(__name__)
logger.info(f"DatabaseSettings init - MYSQL_HOST from env: {os.getenv('MYSQL_HOST', 'NOT SET')}")
logger.info(f"DatabaseSettings init - MYSQL_PORT from env: {os.getenv('MYSQL_PORT', 'NOT SET')}")
logger.info(f"DatabaseSettings init - MYSQL_DB from env: {os.getenv('MYSQL_DB', 'NOT SET')}")
logger.info(f"DatabaseSettings init - MYSQL_USER from env: {os.getenv('MYSQL_USER', 'NOT SET')}")
super().__init__(**kwargs)
logger.info(f"DatabaseSettings after init - host: {self.host}, port: {self.port}, database: {self.database}")
@property
def dsn(self) -> str:
"""Generate database connection string"""
@ -239,6 +251,19 @@ class Settings(BaseSettings):
workflow: WorkflowSettings = Field(default_factory=WorkflowSettings)
app: ApplicationSettings = Field(default_factory=ApplicationSettings)
def __init__(self, **kwargs):
"""Initialize Settings with proper environment variable loading for nested models"""
super().__init__(**kwargs)
# Reinitialize nested settings to ensure they load environment variables
self.database = DatabaseSettings()
self.security = SecuritySettings()
self.oidc = OIDCSettings()
self.email = EmailSettings()
self.rate_limit = RateLimitSettings()
self.storage = StorageSettings()
self.workflow = WorkflowSettings()
self.app = ApplicationSettings()
# Dynamic configuration support
config_file: Optional[Path] = Field(default=None, env="CONFIG_FILE")
config_overrides: Dict[str, Any] = Field(default_factory=dict)

View File

@ -368,34 +368,30 @@ def set_container(container: 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
# Note: Repositories and services will be registered as needed
# The dynamic system doesn't require pre-registered repositories
container.register_repository("application_repository", ApplicationRepository)
container.register_repository("attachment_repository", AttachmentRepository)
# Register core services that might still be needed
try:
from ..services.pdf_service import PDFService
container.register_service("pdf_service", PDFService, singleton=True)
except ImportError:
pass
# Register default services
from ..services.application import ApplicationService
from ..services.pdf import PDFService
from ..services.auth import AuthService
try:
from ..services.auth_service import AuthService
container.register_service("auth_service", AuthService, singleton=True)
except ImportError:
pass
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)
try:
from ..services.notification_service import NotificationService
container.register_service("notification_service", NotificationService, singleton=True)
except ImportError:
pass
return container

View File

@ -5,7 +5,7 @@ This module provides database initialization, connection management,
and migration support for the application.
"""
from typing import Optional, Generator, Any
from typing import Optional, Generator, Any, Dict
from contextlib import contextmanager
import logging
@ -16,15 +16,20 @@ from sqlalchemy.pool import QueuePool
from sqlalchemy.exc import SQLAlchemyError
from ..config.settings import Settings, get_settings
from ..config.database import get_database_url
from ..models.base import Base
from ..models.application import (
Application,
from ..models.application_type import (
ApplicationType,
ApplicationField,
ApplicationTypeStatus,
StatusTransition,
DynamicApplication,
ApplicationHistory,
ApplicationAttachment,
Attachment,
ComparisonOffer,
CostPositionJustification,
Counter
ApplicationTransitionLog,
ApplicationApproval
)
from ..models.user import User, Role, Session as UserSession
logger = logging.getLogger(__name__)
@ -73,7 +78,7 @@ class DatabaseManager:
def _create_engine(self) -> Engine:
"""Create SQLAlchemy engine with configuration"""
engine = create_engine(
self.settings.database.dsn,
get_database_url(),
poolclass=QueuePool,
pool_size=self.settings.database.pool_size,
max_overflow=self.settings.database.max_overflow,
@ -167,31 +172,47 @@ class DatabaseManager:
def _init_default_data(self):
"""Initialize default data in the database"""
with self.session_scope() as session:
# Initialize counters
counters = [
# Initialize default roles if not present
default_roles = [
{
"key": "application_id",
"value": 0,
"prefix": "PA",
"format_string": "{prefix}{value:06d}"
"name": "admin",
"display_name": "Administrator",
"description": "Full system access",
"is_admin": True,
"is_system": True
},
{
"key": "attachment_id",
"value": 0,
"prefix": "ATT",
"format_string": "{prefix}{value:08d}"
"name": "budget_reviewer",
"display_name": "Haushaltsbeauftragte",
"description": "Budget review permissions",
"can_review_budget": True,
"is_system": True
},
{
"name": "finance_reviewer",
"display_name": "Finanzreferent",
"description": "Finance review permissions",
"can_review_finance": True,
"is_system": True
},
{
"name": "asta_member",
"display_name": "AStA Member",
"description": "AStA voting member",
"can_vote": True,
"is_system": True
}
]
for counter_data in counters:
existing = session.query(Counter).filter_by(
key=counter_data["key"]
for role_data in default_roles:
existing = session.query(Role).filter_by(
name=role_data["name"]
).first()
if not existing:
counter = Counter(**counter_data)
session.add(counter)
logger.info(f"Created counter: {counter_data['key']}")
role = Role(**role_data)
session.add(role)
logger.info(f"Created role: {role_data['name']}")
def verify_connection(self) -> bool:
"""
@ -375,12 +396,18 @@ class DatabaseHealthCheck:
manager = get_db_manager()
required_tables = [
"applications",
"attachments",
"application_attachments",
"comparison_offers",
"cost_position_justifications",
"counters"
"application_types",
"application_fields",
"application_type_statuses",
"status_transitions",
"dynamic_applications",
"application_history_v2",
"application_attachments_v2",
"application_transition_logs",
"application_approvals",
"users",
"roles",
"user_roles"
]
with manager.session_scope() as session:

View File

@ -21,15 +21,13 @@ 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
from .api.application_types import router as application_types_router
from .api.dynamic_applications import router as dynamic_applications_router
# Configure logging
logging.basicConfig(
@ -57,10 +55,8 @@ async def lifespan(app: FastAPI):
# 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))
# PDF service will be initialized if needed
# Dynamic system doesn't require pre-registered providers
# Store container in app state
app.state.container = container
@ -123,6 +119,7 @@ def create_app() -> FastAPI:
)
# Include routers
# Note: nginx strips /api/ prefix when proxying, so we don't add it here
app.include_router(
health_router,
prefix="/health",
@ -131,28 +128,32 @@ def create_app() -> FastAPI:
app.include_router(
auth_router,
prefix=f"{settings.app.api_prefix}/auth",
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",
prefix="/attachments",
tags=["attachments"]
)
app.include_router(
pdf_router,
prefix=f"{settings.app.api_prefix}/pdf",
prefix="/pdf",
tags=["pdf"]
)
app.include_router(
application_types_router,
tags=["application-types"]
)
app.include_router(
dynamic_applications_router,
tags=["dynamic-applications"]
)
# Root endpoint
@app.get("/", tags=["root"])
async def root() -> Dict[str, Any]:

View File

@ -0,0 +1,248 @@
"""
Add dynamic application system tables
This migration creates all tables needed for the fully dynamic application system.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from datetime import datetime
# Revision identifiers
revision = 'add_dynamic_application_system'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
"""Create tables for dynamic application system"""
# Create application_types table
op.create_table(
'application_types',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('type_id', sa.String(100), nullable=False, comment='Unique identifier for application type'),
sa.Column('name', sa.String(255), nullable=False, comment='Display name'),
sa.Column('description', sa.Text(), nullable=True, comment='Markdown description'),
sa.Column('pdf_template', sa.LargeBinary(), nullable=True, comment='PDF template blob'),
sa.Column('pdf_template_filename', sa.String(255), nullable=True, comment='Original PDF template filename'),
sa.Column('pdf_field_mapping', sa.JSON(), nullable=False, default={}, comment='Mapping from PDF field names to field IDs'),
sa.Column('is_active', sa.Boolean(), default=True, nullable=False, comment='Whether this type is currently active'),
sa.Column('is_public', sa.Boolean(), default=True, nullable=False, comment='Whether this type is publicly available'),
sa.Column('allowed_roles', sa.JSON(), nullable=True, comment='List of roles allowed to create this type'),
sa.Column('max_cost_positions', sa.Integer(), default=100, nullable=False, comment='Maximum number of cost positions'),
sa.Column('max_comparison_offers', sa.Integer(), default=100, nullable=False, comment='Maximum number of comparison offers'),
sa.Column('version', sa.String(20), default='1.0.0', nullable=False, comment='Version number'),
sa.Column('parent_type_id', sa.Integer(), sa.ForeignKey('application_types.id'), nullable=True, comment='Parent type for versioning'),
sa.Column('usage_count', sa.Integer(), default=0, nullable=False, comment='Number of applications created with this type'),
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.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('type_id'),
sa.Index('idx_apptype_active_public', 'is_active', 'is_public')
)
# Create application_fields table
op.create_table(
'application_fields',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('application_type_id', sa.Integer(), sa.ForeignKey('application_types.id', ondelete='CASCADE'), nullable=False),
sa.Column('field_id', sa.String(100), nullable=False, comment='Unique field identifier within type'),
sa.Column('field_type', sa.Enum(
'TEXT_SHORT', 'TEXT_LONG', 'OPTIONS', 'YESNO', 'MAIL', 'DATE', 'DATETIME',
'AMOUNT', 'CURRENCY_EUR', 'NUMBER', 'FILE', 'SIGNATURE', 'PHONE', 'URL',
'CHECKBOX', 'RADIO', 'SELECT', 'MULTISELECT',
name='fieldtype'
), nullable=False, comment='Field data type'),
sa.Column('name', sa.String(255), nullable=False, comment='Field display name'),
sa.Column('label', sa.String(500), nullable=True, comment='Field label for forms'),
sa.Column('description', sa.Text(), nullable=True, comment='Field help text'),
sa.Column('field_order', sa.Integer(), default=0, nullable=False, comment='Display order'),
sa.Column('is_required', sa.Boolean(), default=False, nullable=False, comment='Whether field is required'),
sa.Column('is_readonly', sa.Boolean(), default=False, nullable=False, comment='Whether field is read-only'),
sa.Column('is_hidden', sa.Boolean(), default=False, nullable=False, comment='Whether field is hidden'),
sa.Column('options', sa.JSON(), nullable=True, comment='List of options for selection fields'),
sa.Column('default_value', sa.Text(), nullable=True, comment='Default field value'),
sa.Column('validation_rules', sa.JSON(), nullable=True, comment='Validation rules (min, max, pattern, etc.)'),
sa.Column('display_conditions', sa.JSON(), nullable=True, comment='Conditions for displaying field'),
sa.Column('placeholder', sa.String(500), nullable=True, comment='Input placeholder text'),
sa.Column('section', sa.String(100), nullable=True, comment='Section identifier for grouping'),
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.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('application_type_id', 'field_id', name='uq_type_field'),
sa.Index('idx_field_type_order', 'application_type_id', 'field_order')
)
# Create application_type_statuses table
op.create_table(
'application_type_statuses',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('application_type_id', sa.Integer(), sa.ForeignKey('application_types.id', ondelete='CASCADE'), nullable=False),
sa.Column('status_id', sa.String(50), nullable=False, comment='Status identifier'),
sa.Column('name', sa.String(100), nullable=False, comment='Status display name'),
sa.Column('description', sa.Text(), nullable=True, comment='Status description'),
sa.Column('is_editable', sa.Boolean(), default=True, nullable=False, comment='Whether application is editable in this status'),
sa.Column('color', sa.String(7), nullable=True, comment='RGB color code (e.g., #FF5733)'),
sa.Column('icon', sa.String(50), nullable=True, comment='Icon identifier'),
sa.Column('display_order', sa.Integer(), default=0, nullable=False, comment='Display order'),
sa.Column('is_initial', sa.Boolean(), default=False, nullable=False, comment='Whether this is the initial status'),
sa.Column('is_final', sa.Boolean(), default=False, nullable=False, comment='Whether this is a final status'),
sa.Column('is_cancelled', sa.Boolean(), default=False, nullable=False, comment='Whether this represents a cancelled state'),
sa.Column('send_notification', sa.Boolean(), default=False, nullable=False, comment='Send notification when entering this status'),
sa.Column('notification_template', sa.Text(), nullable=True, comment='Notification template'),
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.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('application_type_id', 'status_id', name='uq_type_status'),
sa.Index('idx_status_type_order', 'application_type_id', 'display_order')
)
# Create status_transitions table
op.create_table(
'status_transitions',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('from_status_id', sa.Integer(), sa.ForeignKey('application_type_statuses.id', ondelete='CASCADE'), nullable=False),
sa.Column('to_status_id', sa.Integer(), sa.ForeignKey('application_type_statuses.id', ondelete='CASCADE'), nullable=False),
sa.Column('name', sa.String(100), nullable=False, comment='Transition name'),
sa.Column('trigger_type', sa.Enum(
'USER_APPROVAL', 'APPLICANT_ACTION', 'DEADLINE_EXPIRED',
'TIME_ELAPSED', 'CONDITION_MET', 'AUTOMATIC',
name='transitiontriggertype'
), nullable=False, comment='Type of trigger'),
sa.Column('trigger_config', sa.JSON(), nullable=False, default={}, comment='Trigger-specific configuration'),
sa.Column('conditions', sa.JSON(), nullable=True, comment='Additional conditions for transition'),
sa.Column('actions', sa.JSON(), nullable=True, comment='Actions to execute on transition'),
sa.Column('priority', sa.Integer(), default=0, nullable=False, comment='Priority (higher = executed first)'),
sa.Column('is_active', sa.Boolean(), default=True, nullable=False, comment='Whether transition is active'),
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.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('from_status_id', 'to_status_id', 'name', name='uq_transition'),
sa.Index('idx_transition_from_to', 'from_status_id', 'to_status_id')
)
# Create dynamic_applications table
op.create_table(
'dynamic_applications',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('application_id', sa.String(64), nullable=False, comment='Public application ID'),
sa.Column('application_key', sa.String(255), nullable=False, comment='Application access key (hashed)'),
sa.Column('application_type_id', sa.Integer(), sa.ForeignKey('application_types.id'), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('email', sa.String(255), nullable=False, comment='Applicant email'),
sa.Column('status_id', sa.String(50), nullable=False, comment='Current status ID'),
sa.Column('title', sa.String(500), nullable=False, comment='Application title'),
sa.Column('first_name', sa.String(100), nullable=True),
sa.Column('last_name', sa.String(100), nullable=True),
sa.Column('status_changed_at', sa.DateTime(), nullable=True, comment='When status was last changed'),
sa.Column('submitted_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('field_data', sa.JSON(), nullable=False, default={}, comment='Dynamic field values'),
sa.Column('cost_positions', sa.JSON(), nullable=True, comment='List of cost positions (up to 100)'),
sa.Column('comparison_offers', sa.JSON(), nullable=True, comment='List of comparison offers (up to 100)'),
sa.Column('total_amount', sa.Float(), default=0.0, nullable=False, comment='Calculated total amount'),
sa.Column('pdf_generated', sa.Boolean(), default=False, nullable=False),
sa.Column('pdf_generated_at', sa.DateTime(), nullable=True),
sa.Column('pdf_file_path', sa.String(500), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True, comment='Additional metadata'),
sa.Column('search_text', sa.Text(), nullable=True, comment='Concatenated searchable text'),
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.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('application_id'),
sa.Index('idx_dynapp_type_status', 'application_type_id', 'status_id'),
sa.Index('idx_dynapp_email_type', 'email', 'application_type_id'),
sa.Index('idx_dynapp_submitted', 'submitted_at', 'status_id')
)
# Create application_history_v2 table
op.create_table(
'application_history_v2',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('application_id', sa.Integer(), sa.ForeignKey('dynamic_applications.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('action', sa.String(100), nullable=False, comment='Action performed'),
sa.Column('field_changes', sa.JSON(), nullable=True, comment='Changed fields with old/new values'),
sa.Column('comment', sa.Text(), nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('user_agent', sa.String(500), 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.PrimaryKeyConstraint('id'),
sa.Index('idx_history_app', 'application_id')
)
# Create application_attachments_v2 table
op.create_table(
'application_attachments_v2',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('application_id', sa.Integer(), sa.ForeignKey('dynamic_applications.id', ondelete='CASCADE'), nullable=False),
sa.Column('field_id', sa.String(100), nullable=True, comment='Associated field ID'),
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(), sa.ForeignKey('users.id'), 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.PrimaryKeyConstraint('id'),
sa.Index('idx_attachment_app', 'application_id')
)
# Create application_transition_logs table
op.create_table(
'application_transition_logs',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('application_id', sa.Integer(), sa.ForeignKey('dynamic_applications.id', ondelete='CASCADE'), nullable=False),
sa.Column('from_status', sa.String(50), nullable=True),
sa.Column('to_status', sa.String(50), nullable=False),
sa.Column('transition_name', sa.String(100), nullable=True),
sa.Column('trigger_type', sa.String(50), nullable=True),
sa.Column('triggered_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('trigger_data', 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.PrimaryKeyConstraint('id'),
sa.Index('idx_translog_app', 'application_id')
)
# Create application_approvals table
op.create_table(
'application_approvals',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('application_id', sa.Integer(), sa.ForeignKey('dynamic_applications.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('role', sa.String(50), nullable=False, comment='Role of approver'),
sa.Column('decision', sa.String(20), nullable=False, comment='approve, reject, abstain'),
sa.Column('comment', sa.Text(), nullable=True),
sa.Column('status_at_approval', sa.String(50), nullable=True, comment='Status when approval was given'),
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.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('application_id', 'user_id', 'role', name='uq_app_user_role_approval'),
sa.Index('idx_approval_app', 'application_id'),
sa.Index('idx_approval_user', 'user_id')
)
def downgrade():
"""Drop all dynamic application system tables"""
# Drop tables in reverse order due to foreign key constraints
op.drop_table('application_approvals')
op.drop_table('application_transition_logs')
op.drop_table('application_attachments_v2')
op.drop_table('application_history_v2')
op.drop_table('dynamic_applications')
op.drop_table('status_transitions')
op.drop_table('application_type_statuses')
op.drop_table('application_fields')
op.drop_table('application_types')
# Drop enums
op.execute('DROP TYPE IF EXISTS fieldtype')
op.execute('DROP TYPE IF EXISTS transitiontriggertype')

View File

@ -1,620 +0,0 @@
"""
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
)

View File

@ -0,0 +1,955 @@
"""
Dynamic Application Type Models
This module defines the database models for fully dynamic application types.
"""
from sqlalchemy import (
Column, Integer, String, Text, DateTime, JSON, Boolean,
ForeignKey, UniqueConstraint, Index, Float, LargeBinary,
Enum as SQLEnum
)
from sqlalchemy.orm import relationship, backref
from sqlalchemy.dialects.mysql import LONGTEXT
import enum
from typing import Optional, Dict, Any, List
from datetime import datetime
from .base import ExtendedBaseModel, BaseModel, TimestampMixin
class FieldType(enum.Enum):
"""Field type enumeration"""
TEXT_SHORT = "text_short"
TEXT_LONG = "text_long"
OPTIONS = "options"
YESNO = "yesno"
MAIL = "mail"
DATE = "date"
DATETIME = "datetime"
AMOUNT = "amount"
CURRENCY_EUR = "currency_eur"
NUMBER = "number"
FILE = "file"
SIGNATURE = "signature"
PHONE = "phone"
URL = "url"
CHECKBOX = "checkbox"
RADIO = "radio"
SELECT = "select"
MULTISELECT = "multiselect"
class TransitionTriggerType(enum.Enum):
"""Transition trigger type"""
USER_APPROVAL = "user_approval" # N users with role X approve/reject
APPLICANT_ACTION = "applicant_action" # Button clicked by applicant
DEADLINE_EXPIRED = "deadline_expired" # Date deadline passed
TIME_ELAPSED = "time_elapsed" # Timespan elapsed
CONDITION_MET = "condition_met" # Field condition met
AUTOMATIC = "automatic" # Automatic transition
class ApplicationType(ExtendedBaseModel):
"""Dynamic application type definition"""
__tablename__ = "application_types"
# Core fields
type_id = Column(
String(100),
unique=True,
nullable=False,
index=True,
comment="Unique identifier for application type"
)
name = Column(
String(255),
nullable=False,
comment="Display name"
)
description = Column(
Text,
nullable=True,
comment="Markdown description"
)
# PDF Template
pdf_template = Column(
LargeBinary,
nullable=True,
comment="PDF template blob"
)
pdf_template_filename = Column(
String(255),
nullable=True,
comment="Original PDF template filename"
)
# Field mapping (PDF field name -> field ID)
pdf_field_mapping = Column(
JSON,
nullable=False,
default=dict,
comment="Mapping from PDF field names to field IDs"
)
# Configuration
is_active = Column(
Boolean,
default=True,
index=True,
comment="Whether this type is currently active"
)
is_public = Column(
Boolean,
default=True,
comment="Whether this type is publicly available"
)
# Access control
allowed_roles = Column(
JSON,
nullable=True,
default=list,
comment="List of roles allowed to create this type"
)
# Cost configuration
max_cost_positions = Column(
Integer,
default=100,
comment="Maximum number of cost positions"
)
max_comparison_offers = Column(
Integer,
default=100,
comment="Maximum number of comparison offers"
)
# Versioning
version = Column(
String(20),
default="1.0.0",
comment="Version number"
)
parent_type_id = Column(
Integer,
ForeignKey('application_types.id'),
nullable=True,
comment="Parent type for versioning"
)
# Statistics
usage_count = Column(
Integer,
default=0,
comment="Number of applications created with this type"
)
# Relationships
fields = relationship(
"ApplicationField",
back_populates="application_type",
cascade="all, delete-orphan",
order_by="ApplicationField.field_order"
)
statuses = relationship(
"ApplicationTypeStatus",
back_populates="application_type",
cascade="all, delete-orphan"
)
applications = relationship(
"DynamicApplication",
back_populates="application_type"
)
parent_type = relationship(
"ApplicationType",
remote_side="ApplicationType.id",
backref=backref("versions", lazy="dynamic")
)
# Indexes
__table_args__ = (
Index('idx_apptype_active_public', 'is_active', 'is_public'),
)
class ApplicationField(ExtendedBaseModel):
"""Field definition for application types"""
__tablename__ = "application_fields"
application_type_id = Column(
Integer,
ForeignKey('application_types.id', ondelete='CASCADE'),
nullable=False,
index=True
)
field_id = Column(
String(100),
nullable=False,
comment="Unique field identifier within type"
)
field_type = Column(
SQLEnum(FieldType),
nullable=False,
comment="Field data type"
)
name = Column(
String(255),
nullable=False,
comment="Field display name"
)
label = Column(
String(500),
nullable=True,
comment="Field label for forms"
)
description = Column(
Text,
nullable=True,
comment="Field help text"
)
field_order = Column(
Integer,
default=0,
comment="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"
)
# Options for select/radio/checkbox fields
options = Column(
JSON,
nullable=True,
default=list,
comment="List of options for selection fields"
)
# Default value
default_value = Column(
Text,
nullable=True,
comment="Default field value"
)
# Validation rules
validation_rules = Column(
JSON,
nullable=True,
default=dict,
comment="Validation rules (min, max, pattern, etc.)"
)
# Display conditions
display_conditions = Column(
JSON,
nullable=True,
default=dict,
comment="Conditions for displaying field"
)
# Placeholder
placeholder = Column(
String(500),
nullable=True,
comment="Input placeholder text"
)
# Section grouping
section = Column(
String(100),
nullable=True,
comment="Section identifier for grouping"
)
# Relationships
application_type = relationship(
"ApplicationType",
back_populates="fields"
)
# Unique constraint
__table_args__ = (
UniqueConstraint('application_type_id', 'field_id', name='uq_type_field'),
Index('idx_field_type_order', 'application_type_id', 'field_order'),
)
class ApplicationTypeStatus(ExtendedBaseModel):
"""Status definition for application types"""
__tablename__ = "application_type_statuses"
application_type_id = Column(
Integer,
ForeignKey('application_types.id', ondelete='CASCADE'),
nullable=False,
index=True
)
status_id = Column(
String(50),
nullable=False,
comment="Status identifier"
)
name = Column(
String(100),
nullable=False,
comment="Status display name"
)
description = Column(
Text,
nullable=True,
comment="Status description"
)
# Configuration
is_editable = Column(
Boolean,
default=True,
comment="Whether application is editable in this status"
)
color = Column(
String(7),
nullable=True,
comment="RGB color code (e.g., #FF5733)"
)
icon = Column(
String(50),
nullable=True,
comment="Icon identifier"
)
# Order for display
display_order = Column(
Integer,
default=0,
comment="Display order"
)
# Status flags
is_initial = Column(
Boolean,
default=False,
comment="Whether this is the initial status"
)
is_final = Column(
Boolean,
default=False,
comment="Whether this is a final status"
)
is_cancelled = Column(
Boolean,
default=False,
comment="Whether this represents a cancelled state"
)
# Notification configuration
send_notification = Column(
Boolean,
default=False,
comment="Send notification when entering this status"
)
notification_template = Column(
Text,
nullable=True,
comment="Notification template"
)
# Relationships
application_type = relationship(
"ApplicationType",
back_populates="statuses"
)
transitions_from = relationship(
"StatusTransition",
foreign_keys="StatusTransition.from_status_id",
back_populates="from_status",
cascade="all, delete-orphan"
)
transitions_to = relationship(
"StatusTransition",
foreign_keys="StatusTransition.to_status_id",
back_populates="to_status"
)
# Unique constraint
__table_args__ = (
UniqueConstraint('application_type_id', 'status_id', name='uq_type_status'),
Index('idx_status_type_order', 'application_type_id', 'display_order'),
)
class StatusTransition(ExtendedBaseModel):
"""Status transition rules"""
__tablename__ = "status_transitions"
from_status_id = Column(
Integer,
ForeignKey('application_type_statuses.id', ondelete='CASCADE'),
nullable=False,
index=True
)
to_status_id = Column(
Integer,
ForeignKey('application_type_statuses.id', ondelete='CASCADE'),
nullable=False,
index=True
)
name = Column(
String(100),
nullable=False,
comment="Transition name"
)
trigger_type = Column(
SQLEnum(TransitionTriggerType),
nullable=False,
comment="Type of trigger"
)
# Trigger configuration
trigger_config = Column(
JSON,
nullable=False,
default=dict,
comment="Trigger-specific configuration"
)
# Conditions
conditions = Column(
JSON,
nullable=True,
default=dict,
comment="Additional conditions for transition"
)
# Actions
actions = Column(
JSON,
nullable=True,
default=list,
comment="Actions to execute on transition"
)
# Priority for multiple possible transitions
priority = Column(
Integer,
default=0,
comment="Priority (higher = executed first)"
)
is_active = Column(
Boolean,
default=True,
comment="Whether transition is active"
)
# Relationships
from_status = relationship(
"ApplicationTypeStatus",
foreign_keys=[from_status_id],
back_populates="transitions_from"
)
to_status = relationship(
"ApplicationTypeStatus",
foreign_keys=[to_status_id],
back_populates="transitions_to"
)
# Unique constraint
__table_args__ = (
UniqueConstraint('from_status_id', 'to_status_id', 'name', name='uq_transition'),
Index('idx_transition_from_to', 'from_status_id', 'to_status_id'),
)
class DynamicApplication(ExtendedBaseModel):
"""Dynamic application instance"""
__tablename__ = "dynamic_applications"
# Identification
application_id = Column(
String(64),
unique=True,
nullable=False,
index=True,
comment="Public application ID"
)
application_key = Column(
String(255),
nullable=False,
comment="Application access key (hashed)"
)
# Type reference
application_type_id = Column(
Integer,
ForeignKey('application_types.id'),
nullable=False,
index=True
)
# User reference
user_id = Column(
Integer,
ForeignKey('users.id'),
nullable=True,
index=True
)
# Common fields (always present)
email = Column(
String(255),
nullable=False,
index=True,
comment="Applicant email"
)
status_id = Column(
String(50),
nullable=False,
index=True,
comment="Current status ID"
)
title = Column(
String(500),
nullable=False,
comment="Application title"
)
first_name = Column(
String(100),
nullable=True,
index=True
)
last_name = Column(
String(100),
nullable=True,
index=True
)
# Timestamps
status_changed_at = Column(
DateTime,
nullable=True,
comment="When status was last changed"
)
submitted_at = Column(
DateTime,
nullable=True,
index=True
)
completed_at = Column(
DateTime,
nullable=True
)
# Dynamic field data
field_data = Column(
JSON,
nullable=False,
default=dict,
comment="Dynamic field values"
)
# Cost positions (extended)
cost_positions = Column(
JSON,
nullable=True,
default=list,
comment="List of cost positions (up to 100)"
)
comparison_offers = Column(
JSON,
nullable=True,
default=list,
comment="List of comparison offers (up to 100)"
)
total_amount = Column(
Float,
default=0.0,
index=True,
comment="Calculated total amount"
)
# PDF generation
pdf_generated = Column(
Boolean,
default=False
)
pdf_generated_at = Column(
DateTime,
nullable=True
)
pdf_file_path = Column(
String(500),
nullable=True
)
# Metadata
application_metadata = Column(
JSON,
nullable=True,
default=dict,
comment="Additional metadata"
)
# Search optimization
search_text = Column(
Text,
nullable=True,
comment="Concatenated searchable text"
)
# Relationships
application_type = relationship(
"ApplicationType",
back_populates="applications"
)
user = relationship(
"User",
back_populates="dynamic_applications"
)
history = relationship(
"ApplicationHistory",
back_populates="application",
cascade="all, delete-orphan"
)
attachments = relationship(
"ApplicationAttachment",
back_populates="application",
cascade="all, delete-orphan"
)
transitions = relationship(
"ApplicationTransitionLog",
back_populates="application",
cascade="all, delete-orphan"
)
approvals = relationship(
"ApplicationApproval",
back_populates="application",
cascade="all, delete-orphan"
)
# Indexes
__table_args__ = (
Index('idx_dynapp_type_status', 'application_type_id', 'status_id'),
Index('idx_dynapp_email_type', 'email', 'application_type_id'),
Index('idx_dynapp_submitted', 'submitted_at', 'status_id'),
)
def update_search_text(self):
"""Update searchable text from field data"""
parts = [
self.title or '',
self.email or '',
self.first_name or '',
self.last_name or '',
]
# Add field data
if self.field_data:
for key, value in self.field_data.items():
if isinstance(value, str):
parts.append(value)
elif isinstance(value, (list, dict)):
parts.append(str(value))
self.search_text = ' '.join(filter(None, parts))
def calculate_total_amount(self):
"""Calculate total from cost positions"""
total = 0.0
if self.cost_positions:
for pos in self.cost_positions:
if isinstance(pos, dict) and 'amount' in pos:
try:
total += float(pos['amount'])
except (ValueError, TypeError):
pass
self.total_amount = total
class ApplicationHistory(ExtendedBaseModel):
"""Application history tracking"""
__tablename__ = "application_history_v2"
application_id = Column(
Integer,
ForeignKey('dynamic_applications.id', ondelete='CASCADE'),
nullable=False,
index=True
)
user_id = Column(
Integer,
ForeignKey('users.id'),
nullable=True
)
action = Column(
String(100),
nullable=False,
comment="Action performed"
)
field_changes = Column(
JSON,
nullable=True,
default=dict,
comment="Changed fields with old/new values"
)
comment = Column(
Text,
nullable=True
)
ip_address = Column(
String(45),
nullable=True
)
user_agent = Column(
String(500),
nullable=True
)
# Relationships
application = relationship(
"DynamicApplication",
back_populates="history"
)
user = relationship("User")
class ApplicationAttachment(ExtendedBaseModel):
"""Application attachments"""
__tablename__ = "application_attachments_v2"
application_id = Column(
Integer,
ForeignKey('dynamic_applications.id', ondelete='CASCADE'),
nullable=False,
index=True
)
field_id = Column(
String(100),
nullable=True,
comment="Associated field ID"
)
file_name = Column(
String(255),
nullable=False
)
file_path = Column(
String(500),
nullable=False
)
file_size = Column(
Integer,
nullable=False
)
file_type = Column(
String(100),
nullable=True
)
file_hash = Column(
String(64),
nullable=True
)
uploaded_by = Column(
Integer,
ForeignKey('users.id'),
nullable=True
)
# Relationships
application = relationship(
"DynamicApplication",
back_populates="attachments"
)
uploader = relationship("User")
class ApplicationTransitionLog(ExtendedBaseModel):
"""Log of status transitions"""
__tablename__ = "application_transition_logs"
application_id = Column(
Integer,
ForeignKey('dynamic_applications.id', ondelete='CASCADE'),
nullable=False,
index=True
)
from_status = Column(
String(50),
nullable=True
)
to_status = Column(
String(50),
nullable=False
)
transition_name = Column(
String(100),
nullable=True
)
trigger_type = Column(
String(50),
nullable=True
)
triggered_by = Column(
Integer,
ForeignKey('users.id'),
nullable=True
)
trigger_data = Column(
JSON,
nullable=True,
default=dict
)
# Relationships
application = relationship(
"DynamicApplication",
back_populates="transitions"
)
user = relationship("User")
class ApplicationApproval(ExtendedBaseModel):
"""Approval tracking for applications"""
__tablename__ = "application_approvals"
application_id = Column(
Integer,
ForeignKey('dynamic_applications.id', ondelete='CASCADE'),
nullable=False,
index=True
)
user_id = Column(
Integer,
ForeignKey('users.id'),
nullable=False,
index=True
)
role = Column(
String(50),
nullable=False,
comment="Role of approver"
)
decision = Column(
String(20),
nullable=False,
comment="approve, reject, abstain"
)
comment = Column(
Text,
nullable=True
)
status_at_approval = Column(
String(50),
nullable=True,
comment="Status when approval was given"
)
# Relationships
application = relationship(
"DynamicApplication",
back_populates="approvals"
)
user = relationship("User")
# Unique constraint
__table_args__ = (
UniqueConstraint('application_id', 'user_id', 'role', name='uq_app_user_role_approval'),
)

View File

@ -1,458 +0,0 @@
"""
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 [],
}

View File

@ -11,7 +11,7 @@ from sqlalchemy import (
from sqlalchemy.orm import relationship, backref
import enum
from typing import Optional, Dict, Any, List
from datetime import datetime
from datetime import datetime, timedelta
from .base import ExtendedBaseModel, BaseModel, TimestampMixin
@ -123,8 +123,8 @@ class User(ExtendedBaseModel):
lazy="joined"
)
applications = relationship(
"Application",
dynamic_applications = relationship(
"DynamicApplication",
back_populates="user",
cascade="all, delete-orphan"
)

View File

@ -1,400 +0,0 @@
"""
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,245 @@
"""
Role Repository
This module provides data access methods for role management.
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..models.user import Role
from .base import BaseRepository
class RoleRepository(BaseRepository[Role]):
"""Repository for role data access"""
def __init__(self, db: Session):
super().__init__(Role, db)
def get_by_name(self, name: str) -> Optional[Role]:
"""Get role by name"""
return self.db.query(Role).filter(
func.lower(Role.name) == func.lower(name)
).first()
def get_by_oidc_claim(self, claim: str) -> Optional[Role]:
"""Get role by OIDC claim value"""
return self.db.query(Role).filter(
Role.oidc_role_claim == claim
).first()
def get_system_roles(self) -> List[Role]:
"""Get all system roles"""
return self.db.query(Role).filter(
Role.is_system == True
).all()
def get_admin_roles(self) -> List[Role]:
"""Get all admin roles"""
return self.db.query(Role).filter(
Role.is_admin == True
).all()
def get_reviewer_roles(self) -> List[Role]:
"""Get all reviewer roles"""
return self.db.query(Role).filter(
(Role.can_review_budget == True) |
(Role.can_review_finance == True)
).all()
def get_voting_roles(self) -> List[Role]:
"""Get all roles that can vote"""
return self.db.query(Role).filter(
Role.can_vote == True
).all()
def get_oidc_role_mappings(self) -> Dict[str, Role]:
"""Get mapping of OIDC claims to roles"""
roles = self.db.query(Role).filter(
Role.oidc_role_claim.isnot(None)
).all()
return {
role.oidc_role_claim: role
for role in roles
}
def search_roles(
self,
query: Optional[str] = None,
is_system: Optional[bool] = None,
is_admin: Optional[bool] = None,
can_review: Optional[bool] = None,
can_vote: Optional[bool] = None,
limit: int = 100,
offset: int = 0
) -> List[Role]:
"""Search roles with filters"""
q = self.db.query(Role)
if query:
search_term = f"%{query}%"
q = q.filter(
(Role.name.ilike(search_term)) |
(Role.display_name.ilike(search_term)) |
(Role.description.ilike(search_term))
)
if is_system is not None:
q = q.filter(Role.is_system == is_system)
if is_admin is not None:
q = q.filter(Role.is_admin == is_admin)
if can_review is not None:
q = q.filter(
(Role.can_review_budget == can_review) |
(Role.can_review_finance == can_review)
)
if can_vote is not None:
q = q.filter(Role.can_vote == can_vote)
return q.order_by(Role.priority.desc(), Role.name).limit(limit).offset(offset).all()
def create_role(
self,
name: str,
display_name: str,
description: Optional[str] = None,
permissions: Optional[List[str]] = None,
is_system: bool = False,
is_admin: bool = False,
can_review_budget: bool = False,
can_review_finance: bool = False,
can_vote: bool = False,
oidc_role_claim: Optional[str] = None,
priority: int = 0
) -> Role:
"""Create a new role"""
role = Role(
name=name,
display_name=display_name,
description=description,
permissions=permissions or [],
is_system=is_system,
is_admin=is_admin,
can_review_budget=can_review_budget,
can_review_finance=can_review_finance,
can_vote=can_vote,
oidc_role_claim=oidc_role_claim,
priority=priority
)
self.db.add(role)
self.db.commit()
self.db.refresh(role)
return role
def update_permissions(self, role_id: int, permissions: List[str]) -> Optional[Role]:
"""Update role permissions"""
role = self.get_by_id(role_id)
if role and not role.is_system:
role.permissions = permissions
self.db.commit()
self.db.refresh(role)
return role
def update_oidc_mapping(self, role_id: int, oidc_claim: Optional[str]) -> Optional[Role]:
"""Update OIDC claim mapping for a role"""
role = self.get_by_id(role_id)
if role:
role.oidc_role_claim = oidc_claim
self.db.commit()
self.db.refresh(role)
return role
def get_role_by_priority(self, roles: List[str]) -> Optional[Role]:
"""Get the highest priority role from a list of role names"""
if not roles:
return None
return self.db.query(Role).filter(
Role.name.in_(roles)
).order_by(Role.priority.desc()).first()
def count_users_by_role(self) -> Dict[str, int]:
"""Get count of users per role"""
from sqlalchemy import select, func
from ..models.user import User, user_roles
result = self.db.execute(
select(
Role.name,
func.count(user_roles.c.user_id).label('user_count')
)
.select_from(Role)
.outerjoin(user_roles, Role.id == user_roles.c.role_id)
.group_by(Role.id, Role.name)
).all()
return {row.name: row.user_count for row in result}
def has_permission(self, role_id: int, permission: str) -> bool:
"""Check if a role has a specific permission"""
role = self.get_by_id(role_id)
if not role:
return False
return role.has_permission(permission)
def get_default_user_role(self) -> Optional[Role]:
"""Get the default role for new users"""
return self.get_by_name("user")
def get_or_create_default_roles(self) -> Dict[str, Role]:
"""Get or create default system roles"""
default_roles = {
"admin": {
"display_name": "Administrator",
"description": "Full system access",
"is_system": True,
"is_admin": True,
"permissions": ["*"],
"priority": 100
},
"user": {
"display_name": "User",
"description": "Basic user access",
"is_system": True,
"permissions": ["read:own", "write:own", "submit:application"],
"priority": 0
},
"haushaltsbeauftragte": {
"display_name": "Haushaltsbeauftragte(r)",
"description": "Budget reviewer",
"can_review_budget": True,
"permissions": ["review:budget", "read:applications", "comment:applications"],
"priority": 50
},
"finanzreferent": {
"display_name": "Finanzreferent",
"description": "Finance reviewer",
"can_review_finance": True,
"permissions": ["review:finance", "read:applications", "comment:applications"],
"priority": 50
},
"asta": {
"display_name": "AStA Member",
"description": "Can vote on applications",
"can_vote": True,
"permissions": ["vote:applications", "read:applications", "comment:applications"],
"priority": 40
}
}
created_roles = {}
for name, config in default_roles.items():
role = self.get_by_name(name)
if not role:
role = self.create_role(name=name, **config)
created_roles[name] = role
return created_roles

View File

@ -0,0 +1,216 @@
"""
User Repository
This module provides data access methods for user management.
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import or_, and_, func
from ..models.user import User, Role, AuthProvider, VerificationStatus
from .base import BaseRepository
class UserRepository(BaseRepository[User]):
"""Repository for user data access"""
def __init__(self, db: Session):
super().__init__(User, db)
def get_by_email(self, email: str) -> Optional[User]:
"""Get user by email address"""
return self.db.query(User).filter(
func.lower(User.email) == func.lower(email)
).first()
def get_by_oidc_sub(self, sub: str, issuer: str) -> Optional[User]:
"""Get user by OIDC subject identifier"""
return self.db.query(User).filter(
User.oidc_sub == sub,
User.oidc_issuer == issuer
).first()
def get_by_verification_token(self, token_hash: str) -> Optional[User]:
"""Get user by email verification token"""
return self.db.query(User).filter(
User.email_verification_token == token_hash
).first()
def get_by_auth_provider(self, provider: AuthProvider) -> List[User]:
"""Get all users using a specific auth provider"""
return self.db.query(User).filter(
User.auth_provider == provider
).all()
def search_users(
self,
query: Optional[str] = None,
verification_status: Optional[VerificationStatus] = None,
auth_provider: Optional[AuthProvider] = None,
has_role: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[User]:
"""Search users with filters"""
q = self.db.query(User)
if query:
search_term = f"%{query}%"
q = q.filter(
or_(
User.email.ilike(search_term),
User.given_name.ilike(search_term),
User.family_name.ilike(search_term),
User.display_name.ilike(search_term),
User.preferred_username.ilike(search_term)
)
)
if verification_status:
q = q.filter(User.verification_status == verification_status)
if auth_provider:
q = q.filter(User.auth_provider == auth_provider)
if has_role:
q = q.join(User.roles).filter(Role.name == has_role)
return q.limit(limit).offset(offset).all()
def count_by_verification_status(self) -> Dict[str, int]:
"""Get count of users by verification status"""
counts = self.db.query(
User.verification_status,
func.count(User.id)
).group_by(User.verification_status).all()
return {
status.value if status else 'unknown': count
for status, count in counts
}
def get_users_with_role(self, role_name: str) -> List[User]:
"""Get all users with a specific role"""
return self.db.query(User).join(User.roles).filter(
Role.name == role_name
).all()
def get_admin_users(self) -> List[User]:
"""Get all admin users"""
return self.db.query(User).join(User.roles).filter(
Role.is_admin == True
).all()
def get_reviewers(self, review_type: str) -> List[User]:
"""Get users who can review applications"""
if review_type == "budget":
return self.db.query(User).join(User.roles).filter(
Role.can_review_budget == True
).all()
elif review_type == "finance":
return self.db.query(User).join(User.roles).filter(
Role.can_review_finance == True
).all()
else:
return []
def get_voters(self) -> List[User]:
"""Get users who can vote on applications"""
return self.db.query(User).join(User.roles).filter(
Role.can_vote == True
).all()
def update_last_login(self, user_id: int) -> Optional[User]:
"""Update user's last login timestamp"""
from datetime import datetime
user = self.get_by_id(user_id)
if user:
user.last_login_at = datetime.utcnow()
self.db.commit()
self.db.refresh(user)
return user
def update_last_activity(self, user_id: int) -> Optional[User]:
"""Update user's last activity timestamp"""
from datetime import datetime
user = self.get_by_id(user_id)
if user:
user.last_activity_at = datetime.utcnow()
self.db.commit()
self.db.refresh(user)
return user
def verify_email(self, user_id: int) -> Optional[User]:
"""Mark user's email as verified"""
from datetime import datetime
user = self.get_by_id(user_id)
if user:
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_verification_token = None
# Update verification status
if user.auth_provider == AuthProvider.EMAIL:
user.verification_status = VerificationStatus.EMAIL_VERIFIED
elif user.auth_provider == AuthProvider.OIDC:
user.verification_status = VerificationStatus.FULLY_VERIFIED
self.db.commit()
self.db.refresh(user)
return user
def add_role(self, user_id: int, role: Role) -> Optional[User]:
"""Add a role to a user"""
user = self.get_by_id(user_id)
if user and role not in user.roles:
user.roles.append(role)
self.db.commit()
self.db.refresh(user)
return user
def remove_role(self, user_id: int, role: Role) -> Optional[User]:
"""Remove a role from a user"""
user = self.get_by_id(user_id)
if user and role in user.roles:
user.roles.remove(role)
self.db.commit()
self.db.refresh(user)
return user
def set_roles(self, user_id: int, roles: List[Role]) -> Optional[User]:
"""Set user's roles (replaces existing)"""
user = self.get_by_id(user_id)
if user:
user.roles = roles
self.db.commit()
self.db.refresh(user)
return user
def get_inactive_users(self, days: int = 30) -> List[User]:
"""Get users who haven't been active for specified days"""
from datetime import datetime, timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days)
return self.db.query(User).filter(
or_(
User.last_activity_at < cutoff_date,
and_(
User.last_activity_at.is_(None),
User.last_login_at < cutoff_date
)
)
).all()
def get_unverified_users(self, days_old: int = 7) -> List[User]:
"""Get unverified users older than specified days"""
from datetime import datetime, timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
return self.db.query(User).filter(
User.verification_status == VerificationStatus.UNVERIFIED,
User.created_at < cutoff_date
).all()

View File

@ -0,0 +1,259 @@
"""
Authentication service with dependency injections for FastAPI
"""
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from jose import JWTError, jwt
from datetime import datetime, timedelta
import os
from ..config.database import get_db
from ..models.user import User
# Security
security = HTTPBearer(auto_error=False)
# JWT Configuration
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""
Create a JWT access token
Args:
data: Data to encode in the token
expires_delta: Optional custom expiration time
Returns:
Encoded JWT token
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> dict:
"""
Decode and verify a JWT access token
Args:
token: JWT token to decode
Returns:
Decoded token payload
Raises:
HTTPException: If token is invalid or expired
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""
Get the current authenticated user from JWT token
Args:
credentials: HTTP Bearer token credentials
db: Database session
Returns:
Current authenticated user
Raises:
HTTPException: If authentication fails
"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
try:
payload = decode_access_token(token)
user_id: int = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return user
async def get_optional_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(get_db)
) -> Optional[User]:
"""
Get the current user if authenticated, otherwise return None
Args:
credentials: Optional HTTP Bearer token credentials
db: Database session
Returns:
Current authenticated user or None
"""
if not credentials:
return None
try:
return await get_current_user(credentials, db)
except HTTPException:
return None
async def require_admin(
current_user: User = Depends(get_current_user)
) -> User:
"""
Require the current user to have admin role
Args:
current_user: Current authenticated user
Returns:
Current user if admin
Raises:
HTTPException: If user is not admin
"""
if not current_user.has_role("admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
return current_user
async def require_roles(roles: list):
"""
Create a dependency that requires specific roles
Args:
roles: List of role names that are allowed
Returns:
Dependency function
"""
async def role_checker(current_user: User = Depends(get_current_user)) -> User:
if not current_user.has_any_role(roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"One of these roles required: {', '.join(roles)}"
)
return current_user
return role_checker
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a plain password against a hashed password
Args:
plain_password: Plain text password
hashed_password: Hashed password to compare against
Returns:
True if password matches
"""
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password for storage
Args:
password: Plain text password
Returns:
Hashed password
"""
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.hash(password)
# Helper functions for specific role checks
async def require_budget_reviewer(
current_user: User = Depends(get_current_user)
) -> User:
"""Require budget reviewer role"""
if not current_user.has_role("budget_reviewer") and not current_user.has_role("admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Budget reviewer access required"
)
return current_user
async def require_finance_reviewer(
current_user: User = Depends(get_current_user)
) -> User:
"""Require finance reviewer role"""
if not current_user.has_role("finance_reviewer") and not current_user.has_role("admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Finance reviewer access required"
)
return current_user
async def require_asta_member(
current_user: User = Depends(get_current_user)
) -> User:
"""Require AStA member role"""
if not current_user.has_role("asta_member") and not current_user.has_role("admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="AStA member access required"
)
return current_user

View File

@ -0,0 +1,409 @@
"""
Notification service for sending emails and notifications
"""
import smtplib
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from typing import List, Optional, Dict, Any
import logging
from jinja2 import Template
from datetime import datetime
import asyncio
import aiosmtplib
from pathlib import Path
logger = logging.getLogger(__name__)
class NotificationService:
"""Service for sending notifications"""
def __init__(self):
self.smtp_host = os.getenv("SMTP_HOST", "localhost")
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
self.smtp_user = os.getenv("SMTP_USER", "")
self.smtp_password = os.getenv("SMTP_PASSWORD", "")
self.smtp_use_tls = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
self.smtp_use_ssl = os.getenv("SMTP_USE_SSL", "false").lower() == "true"
self.from_email = os.getenv("FROM_EMAIL", "noreply@example.com")
self.from_name = os.getenv("FROM_NAME", "Application System")
self.reply_to_email = os.getenv("REPLY_TO_EMAIL", "")
self.base_url = os.getenv("BASE_URL", "http://localhost:3000")
def _render_template(self, template: str, context: Dict[str, Any]) -> str:
"""
Render a template with context
Args:
template: Template string (can include Jinja2 syntax)
context: Context dictionary for template rendering
Returns:
Rendered template string
"""
try:
jinja_template = Template(template)
return jinja_template.render(**context)
except Exception as e:
logger.error(f"Failed to render template: {str(e)}")
return template
def _create_message(
self,
to_email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
attachments: Optional[List[tuple]] = None,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None
) -> MIMEMultipart:
"""
Create an email message
Args:
to_email: Recipient email address
subject: Email subject
body: Plain text body
html_body: Optional HTML body
attachments: Optional list of (filename, content) tuples
cc: Optional list of CC recipients
bcc: Optional list of BCC recipients
Returns:
MIMEMultipart message object
"""
msg = MIMEMultipart('alternative')
msg['From'] = f"{self.from_name} <{self.from_email}>"
msg['To'] = to_email
msg['Subject'] = subject
if self.reply_to_email:
msg['Reply-To'] = self.reply_to_email
if cc:
msg['Cc'] = ', '.join(cc)
if bcc:
msg['Bcc'] = ', '.join(bcc)
# Add plain text part
msg.attach(MIMEText(body, 'plain'))
# Add HTML part if provided
if html_body:
msg.attach(MIMEText(html_body, 'html'))
# Add attachments
if attachments:
for filename, content in attachments:
part = MIMEBase('application', 'octet-stream')
part.set_payload(content)
encoders.encode_base64(part)
part.add_header(
'Content-Disposition',
f'attachment; filename= {filename}'
)
msg.attach(part)
return msg
def send_email(
self,
to_email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
attachments: Optional[List[tuple]] = None,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
context: Optional[Dict[str, Any]] = None
) -> bool:
"""
Send an email synchronously
Args:
to_email: Recipient email address
subject: Email subject
body: Plain text body
html_body: Optional HTML body
attachments: Optional list of (filename, content) tuples
cc: Optional list of CC recipients
bcc: Optional list of BCC recipients
context: Optional context for template rendering
Returns:
True if email was sent successfully
"""
try:
# Render templates if context is provided
if context:
subject = self._render_template(subject, context)
body = self._render_template(body, context)
if html_body:
html_body = self._render_template(html_body, context)
msg = self._create_message(
to_email, subject, body, html_body,
attachments, cc, bcc
)
# Send email
if self.smtp_use_ssl:
server = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port)
else:
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
if self.smtp_use_tls:
server.starttls()
if self.smtp_user and self.smtp_password:
server.login(self.smtp_user, self.smtp_password)
all_recipients = [to_email]
if cc:
all_recipients.extend(cc)
if bcc:
all_recipients.extend(bcc)
server.send_message(msg, self.from_email, all_recipients)
server.quit()
logger.info(f"Email sent successfully to {to_email}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {str(e)}")
return False
async def send_email_async(
self,
to_email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
attachments: Optional[List[tuple]] = None,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
context: Optional[Dict[str, Any]] = None
) -> bool:
"""
Send an email asynchronously
Args:
to_email: Recipient email address
subject: Email subject
body: Plain text body
html_body: Optional HTML body
attachments: Optional list of (filename, content) tuples
cc: Optional list of CC recipients
bcc: Optional list of BCC recipients
context: Optional context for template rendering
Returns:
True if email was sent successfully
"""
try:
# Render templates if context is provided
if context:
subject = self._render_template(subject, context)
body = self._render_template(body, context)
if html_body:
html_body = self._render_template(html_body, context)
msg = self._create_message(
to_email, subject, body, html_body,
attachments, cc, bcc
)
# Send email asynchronously
await aiosmtplib.send(
msg,
hostname=self.smtp_host,
port=self.smtp_port,
username=self.smtp_user if self.smtp_user else None,
password=self.smtp_password if self.smtp_password else None,
use_tls=self.smtp_use_tls,
start_tls=self.smtp_use_tls and not self.smtp_use_ssl
)
logger.info(f"Email sent successfully to {to_email}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {str(e)}")
return False
def send_application_created(
self,
to_email: str,
application_id: str,
access_key: str,
application_title: str,
status: str
):
"""
Send application created notification
Args:
to_email: Recipient email
application_id: Application ID
access_key: Access key for the application
application_title: Application title
status: Current status
"""
subject = "Application Created Successfully"
body = f"""
Dear Applicant,
Your application "{application_title}" has been created successfully.
Application ID: {application_id}
Status: {status}
You can access your application at:
{self.base_url}/applications/{application_id}?key={access_key}
Please save this link for future reference. You will need it to access and update your application.
Best regards,
{self.from_name}
"""
html_body = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #f8f9fa; padding: 20px; border-radius: 5px; margin-bottom: 20px; }}
.info-box {{ background-color: #e7f3ff; padding: 15px; border-left: 4px solid #0066cc; margin: 20px 0; }}
.button {{ display: inline-block; padding: 10px 20px; background-color: #0066cc; color: white; text-decoration: none; border-radius: 5px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Application Created Successfully</h2>
</div>
<p>Dear Applicant,</p>
<p>Your application "<strong>{application_title}</strong>" has been created successfully.</p>
<div class="info-box">
<p><strong>Application ID:</strong> {application_id}</p>
<p><strong>Status:</strong> {status}</p>
</div>
<p>You can access your application at:</p>
<p style="text-align: center;">
<a href="{self.base_url}/applications/{application_id}?key={access_key}" class="button">
View Application
</a>
</p>
<p><strong>Important:</strong> Please save this link for future reference. You will need it to access and update your application.</p>
<p>Best regards,<br>{self.from_name}</p>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body, html_body)
def send_status_change(
self,
to_email: str,
application_id: str,
application_title: str,
old_status: str,
new_status: str,
comment: Optional[str] = None
):
"""
Send status change notification
Args:
to_email: Recipient email
application_id: Application ID
application_title: Application title
old_status: Previous status
new_status: New status
comment: Optional comment
"""
subject = f"Application Status Changed: {new_status}"
body = f"""
Dear Applicant,
The status of your application "{application_title}" has been changed.
Application ID: {application_id}
Previous Status: {old_status}
New Status: {new_status}
{f'Comment: {comment}' if comment else ''}
You can view your application at:
{self.base_url}/applications/{application_id}
Best regards,
{self.from_name}
"""
return self.send_email(to_email, subject, body)
# Singleton instance
notification_service = NotificationService()
# Convenience functions for background tasks
async def send_notification(
to_email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
attachments: Optional[List[tuple]] = None
):
"""
Send a notification email (for use in background tasks)
Args:
to_email: Recipient email
subject: Email subject
body: Plain text body
html_body: Optional HTML body
attachments: Optional attachments
"""
await notification_service.send_email_async(
to_email, subject, body, html_body, attachments
)
def send_notification_sync(
to_email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
attachments: Optional[List[tuple]] = None
):
"""
Send a notification email synchronously
Args:
to_email: Recipient email
subject: Email subject
body: Plain text body
html_body: Optional HTML body
attachments: Optional attachments
"""
notification_service.send_email(
to_email, subject, body, html_body, attachments
)

View File

@ -0,0 +1,392 @@
"""
PDF generation service for applications
"""
import os
import io
from typing import Optional, Dict, Any, List
from datetime import datetime
from pathlib import Path
import hashlib
from sqlalchemy.orm import Session
from ..models.application_type import (
DynamicApplication, ApplicationType, ApplicationField,
ApplicationTypeStatus, FieldType
)
from ..utils.pdf_utils import (
fill_pdf_template, create_pdf_from_data,
add_watermark_to_pdf, merge_pdfs
)
class PDFService:
"""Service for generating PDFs from applications"""
def __init__(self):
self.output_dir = Path(os.getenv("PDF_OUTPUT_DIR", "./uploads/pdfs"))
self.output_dir.mkdir(parents=True, exist_ok=True)
def generate_pdf_for_application(
self,
application: DynamicApplication,
db: Session,
include_watermark: bool = False,
watermark_text: Optional[str] = None
) -> str:
"""
Generate a PDF for an application
Args:
application: The application to generate PDF for
db: Database session
include_watermark: Whether to include a watermark
watermark_text: Custom watermark text
Returns:
Path to the generated PDF file
"""
# Get application type
app_type = application.application_type
# Prepare data for PDF
pdf_data = self._prepare_pdf_data(application, app_type, db)
# Generate PDF
if app_type.pdf_template:
# Use template if available
pdf_content = self._generate_from_template(
app_type.pdf_template,
app_type.pdf_field_mapping,
pdf_data
)
else:
# Generate from scratch
pdf_content = self._generate_from_scratch(
pdf_data,
app_type.name,
application.title
)
# Add watermark if requested
if include_watermark:
if not watermark_text:
watermark_text = self._get_default_watermark(application, db)
pdf_content = add_watermark_to_pdf(pdf_content, watermark_text)
# Save to file
filename = self._generate_filename(application)
filepath = self.output_dir / filename
with open(filepath, 'wb') as f:
f.write(pdf_content)
return str(filepath)
def _prepare_pdf_data(
self,
application: DynamicApplication,
app_type: ApplicationType,
db: Session
) -> Dict[str, Any]:
"""
Prepare data for PDF generation
Args:
application: The application
app_type: Application type
db: Database session
Returns:
Dictionary of data for PDF
"""
# Start with common fields
data = {
"application_id": application.application_id,
"type": app_type.name,
"title": application.title,
"email": application.email,
"first_name": application.first_name or "",
"last_name": application.last_name or "",
"submitted_at": application.submitted_at.strftime("%d.%m.%Y %H:%M") if application.submitted_at else "",
"created_at": application.created_at.strftime("%d.%m.%Y %H:%M") if application.created_at else "",
"status": self._get_status_name(application, app_type, db),
"total_amount": f"{application.total_amount:.2f}"
}
# Add field data with proper formatting
for field in app_type.fields:
field_id = field.field_id
value = application.field_data.get(field_id)
if value is not None:
formatted_value = self._format_field_value(value, field)
data[field_id] = formatted_value
# Also add with field name as key for better template compatibility
data[field.name.lower().replace(' ', '_')] = formatted_value
# Add cost positions
if application.cost_positions:
data["cost_positions"] = self._format_cost_positions(application.cost_positions)
data["cost_positions_count"] = len(application.cost_positions)
# Add comparison offers
if application.comparison_offers:
data["comparison_offers"] = self._format_comparison_offers(application.comparison_offers)
data["comparison_offers_count"] = len(application.comparison_offers)
return data
def _format_field_value(self, value: Any, field: ApplicationField) -> str:
"""
Format a field value for PDF display
Args:
value: The raw value
field: Field definition
Returns:
Formatted string value
"""
if value is None or value == "":
return ""
if field.field_type == FieldType.YESNO:
return "Ja" if value else "Nein"
elif field.field_type == FieldType.DATE:
try:
# Parse and format date
from datetime import datetime
if isinstance(value, str):
# Try different formats
for fmt in ["%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"]:
try:
dt = datetime.strptime(value, fmt)
return dt.strftime("%d.%m.%Y")
except:
continue
return str(value)
except:
return str(value)
elif field.field_type == FieldType.DATETIME:
try:
if isinstance(value, str):
dt = datetime.fromisoformat(value)
return dt.strftime("%d.%m.%Y %H:%M")
return str(value)
except:
return str(value)
elif field.field_type in [FieldType.AMOUNT, FieldType.CURRENCY_EUR]:
try:
amount = float(value)
return f"{amount:.2f}"
except:
return str(value)
elif field.field_type == FieldType.MULTISELECT:
if isinstance(value, list):
return ", ".join(str(v) for v in value)
return str(value)
else:
return str(value)
def _format_cost_positions(self, positions: List[Dict]) -> List[Dict]:
"""Format cost positions for PDF"""
formatted = []
for i, pos in enumerate(positions, 1):
formatted.append({
"number": i,
"description": pos.get("description", ""),
"amount": f"{float(pos.get('amount', 0)):.2f}",
"category": pos.get("category", ""),
"notes": pos.get("notes", "")
})
return formatted
def _format_comparison_offers(self, offers: List[Dict]) -> List[Dict]:
"""Format comparison offers for PDF"""
formatted = []
for i, offer in enumerate(offers, 1):
formatted.append({
"number": i,
"vendor": offer.get("vendor", ""),
"description": offer.get("description", ""),
"amount": f"{float(offer.get('amount', 0)):.2f}",
"selected": "" if offer.get("selected") else "",
"notes": offer.get("notes", "")
})
return formatted
def _get_status_name(
self,
application: DynamicApplication,
app_type: ApplicationType,
db: Session
) -> str:
"""Get the display name for the current status"""
status = db.query(ApplicationTypeStatus).filter(
ApplicationTypeStatus.application_type_id == app_type.id,
ApplicationTypeStatus.status_id == application.status_id
).first()
return status.name if status else application.status_id
def _generate_from_template(
self,
template_content: bytes,
field_mapping: Dict[str, str],
data: Dict[str, Any]
) -> bytes:
"""Generate PDF from template"""
return fill_pdf_template(template_content, field_mapping, data)
def _generate_from_scratch(
self,
data: Dict[str, Any],
type_name: str,
title: str
) -> bytes:
"""Generate PDF from scratch"""
# Prepare formatted data
formatted_data = {
"Application Type": type_name,
"Title": title,
"Application ID": data.get("application_id", ""),
"Status": data.get("status", ""),
"Submitted": data.get("submitted_at", ""),
"Email": data.get("email", ""),
"Name": f"{data.get('first_name', '')} {data.get('last_name', '')}".strip(),
"Total Amount": data.get("total_amount", "")
}
# Add other fields
for key, value in data.items():
if key not in ["application_id", "type", "title", "email", "first_name",
"last_name", "submitted_at", "created_at", "status",
"total_amount", "cost_positions", "comparison_offers",
"cost_positions_count", "comparison_offers_count"]:
# Format key for display
display_key = key.replace('_', ' ').title()
formatted_data[display_key] = value
# Add cost positions if present
if "cost_positions" in data and data["cost_positions"]:
formatted_data["Cost Positions"] = data["cost_positions"]
# Add comparison offers if present
if "comparison_offers" in data and data["comparison_offers"]:
formatted_data["Comparison Offers"] = data["comparison_offers"]
return create_pdf_from_data(formatted_data, title)
def _get_default_watermark(
self,
application: DynamicApplication,
db: Session
) -> str:
"""Get default watermark text"""
status = self._get_status_name(application, application.application_type, db)
if "draft" in status.lower():
return "ENTWURF"
elif "approved" in status.lower() or "genehmigt" in status.lower():
return "GENEHMIGT"
elif "rejected" in status.lower() or "abgelehnt" in status.lower():
return "ABGELEHNT"
else:
return status.upper()
def _generate_filename(self, application: DynamicApplication) -> str:
"""Generate a unique filename for the PDF"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_title = "".join(c for c in application.title if c.isalnum() or c in (' ', '-', '_'))[:50]
safe_title = safe_title.replace(' ', '_')
return f"{application.application_id}_{safe_title}_{timestamp}.pdf"
def generate_batch_pdfs(
self,
application_ids: List[str],
db: Session,
merge: bool = False
) -> Optional[str]:
"""
Generate PDFs for multiple applications
Args:
application_ids: List of application IDs
db: Database session
merge: Whether to merge into a single PDF
Returns:
Path to the generated file(s)
"""
pdf_contents = []
pdf_paths = []
for app_id in application_ids:
application = db.query(DynamicApplication).filter(
DynamicApplication.application_id == app_id
).first()
if application:
path = self.generate_pdf_for_application(application, db)
pdf_paths.append(path)
if merge:
with open(path, 'rb') as f:
pdf_contents.append(f.read())
if merge and pdf_contents:
# Merge all PDFs
merged_content = merge_pdfs(pdf_contents)
# Save merged PDF
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
merged_filename = f"batch_{timestamp}.pdf"
merged_path = self.output_dir / merged_filename
with open(merged_path, 'wb') as f:
f.write(merged_content)
# Delete individual PDFs
for path in pdf_paths:
try:
os.remove(path)
except:
pass
return str(merged_path)
return pdf_paths[0] if pdf_paths else None
# Singleton instance
pdf_service = PDFService()
# Convenience function for use in routes
def generate_pdf_for_application(
application: DynamicApplication,
db: Session,
include_watermark: bool = False,
watermark_text: Optional[str] = None
) -> str:
"""
Generate a PDF for an application
Args:
application: The application
db: Database session
include_watermark: Whether to include watermark
watermark_text: Custom watermark text
Returns:
Path to generated PDF
"""
return pdf_service.generate_pdf_for_application(
application, db, include_watermark, watermark_text
)

260
backend/src/startup.py Normal file
View File

@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
Application Startup Script
This script initializes the database and creates default data for the dynamic application system.
"""
import sys
import os
import logging
# Add src directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config.database import init_database, get_engine
from sqlalchemy.orm import Session
from models.application_type import (
ApplicationType, ApplicationField, ApplicationTypeStatus,
StatusTransition, FieldType, TransitionTriggerType
)
from models.user import User, Role
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def create_default_application_types():
"""Create default QSM and VSM application types if they don't exist"""
engine = get_engine()
with Session(engine) as session:
# Check if types already exist
existing_qsm = session.query(ApplicationType).filter_by(type_id="qsm").first()
existing_vsm = session.query(ApplicationType).filter_by(type_id="vsm").first()
if existing_qsm and existing_vsm:
logger.info("Default application types already exist")
return
# Create QSM type if not exists
if not existing_qsm:
logger.info("Creating QSM application type...")
qsm_type = ApplicationType(
type_id="qsm",
name="QSM - Qualitätssicherungsmittel",
description="Antrag für Qualitätssicherungsmittel zur Verbesserung der Lehre",
is_active=True,
is_public=True,
max_cost_positions=100,
max_comparison_offers=100
)
session.add(qsm_type)
session.flush()
# Create default statuses for QSM
create_default_statuses(session, qsm_type)
logger.info("QSM application type created successfully")
# Create VSM type if not exists
if not existing_vsm:
logger.info("Creating VSM application type...")
vsm_type = ApplicationType(
type_id="vsm",
name="VSM - Verfasste Studierendenschaft",
description="Antrag für Mittel der Verfassten Studierendenschaft",
is_active=True,
is_public=True,
max_cost_positions=100,
max_comparison_offers=100
)
session.add(vsm_type)
session.flush()
# Create default statuses for VSM
create_default_statuses(session, vsm_type)
logger.info("VSM application type created successfully")
session.commit()
logger.info("Default application types initialization complete")
def create_default_statuses(session, app_type: ApplicationType):
"""Create default statuses and transitions for an application type"""
# Define default statuses
statuses = [
{
"status_id": "draft",
"name": "Entwurf",
"is_editable": True,
"color": "#6B7280",
"is_initial": True,
"display_order": 0
},
{
"status_id": "submitted",
"name": "Eingereicht",
"is_editable": False,
"color": "#3B82F6",
"send_notification": True,
"display_order": 10
},
{
"status_id": "under_review",
"name": "In Prüfung",
"is_editable": False,
"color": "#8B5CF6",
"display_order": 20
},
{
"status_id": "approved",
"name": "Genehmigt",
"is_editable": False,
"color": "#10B981",
"is_final": True,
"send_notification": True,
"display_order": 30
},
{
"status_id": "rejected",
"name": "Abgelehnt",
"is_editable": False,
"color": "#EF4444",
"is_final": True,
"send_notification": True,
"display_order": 40
}
]
status_objects = {}
for status_data in statuses:
status = ApplicationTypeStatus(
application_type_id=app_type.id,
**status_data
)
session.add(status)
session.flush()
status_objects[status_data["status_id"]] = status
# Create transitions
transitions = [
{
"from": "draft",
"to": "submitted",
"name": "Einreichen",
"trigger_type": TransitionTriggerType.APPLICANT_ACTION
},
{
"from": "submitted",
"to": "under_review",
"name": "Prüfung starten",
"trigger_type": TransitionTriggerType.USER_APPROVAL,
"config": {"role": "admin"}
},
{
"from": "under_review",
"to": "approved",
"name": "Genehmigen",
"trigger_type": TransitionTriggerType.USER_APPROVAL,
"config": {"role": "admin"}
},
{
"from": "under_review",
"to": "rejected",
"name": "Ablehnen",
"trigger_type": TransitionTriggerType.USER_APPROVAL,
"config": {"role": "admin"}
}
]
for trans in transitions:
transition = StatusTransition(
from_status_id=status_objects[trans["from"]].id,
to_status_id=status_objects[trans["to"]].id,
name=trans["name"],
trigger_type=trans["trigger_type"],
trigger_config=trans.get("config", {}),
is_active=True
)
session.add(transition)
def create_admin_user():
"""Create a default admin user if none exists"""
engine = get_engine()
with Session(engine) as session:
# Check if any admin user exists
admin_role = session.query(Role).filter_by(name="admin").first()
if not admin_role:
logger.warning("Admin role not found, skipping admin user creation")
return
admin_users = session.query(User).join(User.roles).filter(Role.name == "admin").all()
if admin_users:
logger.info("Admin user(s) already exist")
return
logger.info("Creating default admin user...")
# Create admin user
from services.auth_service import get_password_hash
admin = User(
email="admin@example.com",
given_name="System",
family_name="Administrator",
display_name="System Admin",
auth_provider="local",
verification_status="fully_verified",
email_verified=True
)
# Set password (you should change this!)
# For production, this should be set via environment variable
default_password = os.getenv("ADMIN_PASSWORD", "changeme123")
# Note: You'll need to implement password storage separately
# This is just a placeholder
session.add(admin)
admin.roles.append(admin_role)
session.commit()
logger.info(f"Default admin user created: admin@example.com")
logger.warning(f"IMPORTANT: Change the default admin password immediately!")
def main():
"""Main startup function"""
logger.info("Starting application initialization...")
try:
# Initialize database schema
logger.info("Initializing database schema...")
init_database()
logger.info("Database schema initialized")
# Create default application types
create_default_application_types()
# Create admin user
create_admin_user()
logger.info("Application initialization complete!")
logger.info("You can now start the application server.")
except Exception as e:
logger.error(f"Initialization failed: {e}", exc_info=True)
sys.exit(1)
if __name__ == "__main__":
main()

139
backend/src/utils/crypto.py Normal file
View File

@ -0,0 +1,139 @@
"""
Cryptography Utilities
This module provides encryption and decryption utilities for sensitive data.
"""
import base64
import secrets
import hashlib
from typing import Optional
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2
def generate_key(password: str, salt: Optional[bytes] = None) -> bytes:
"""Generate an encryption key from a password"""
if salt is None:
salt = secrets.token_bytes(16)
kdf = PBKDF2(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(password.encode()))
return key
def encrypt_token(token: str, key: str) -> str:
"""Encrypt a token using Fernet encryption"""
if not token or not key:
return ""
# Ensure key is properly formatted
if len(key) < 32:
# Pad the key if too short
key = key.ljust(32, '0')
# Create Fernet key from the provided key
fernet_key = base64.urlsafe_b64encode(key[:32].encode())
f = Fernet(fernet_key)
# Encrypt the token
encrypted = f.encrypt(token.encode())
return base64.urlsafe_b64encode(encrypted).decode()
def decrypt_token(encrypted_token: str, key: str) -> Optional[str]:
"""Decrypt a token using Fernet encryption"""
if not encrypted_token or not key:
return None
try:
# Ensure key is properly formatted
if len(key) < 32:
# Pad the key if too short
key = key.ljust(32, '0')
# Create Fernet key from the provided key
fernet_key = base64.urlsafe_b64encode(key[:32].encode())
f = Fernet(fernet_key)
# Decode and decrypt the token
encrypted_bytes = base64.urlsafe_b64decode(encrypted_token.encode())
decrypted = f.decrypt(encrypted_bytes)
return decrypted.decode()
except Exception:
return None
def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
"""Hash a password with salt"""
if salt is None:
salt = secrets.token_hex(16)
# Create hash
password_hash = hashlib.pbkdf2_hmac(
'sha256',
password.encode(),
salt.encode(),
100000
)
return base64.b64encode(password_hash).decode(), salt
def verify_password(password: str, password_hash: str, salt: str) -> bool:
"""Verify a password against its hash"""
try:
# Recreate hash with provided password and salt
new_hash = hashlib.pbkdf2_hmac(
'sha256',
password.encode(),
salt.encode(),
100000
)
new_hash_str = base64.b64encode(new_hash).decode()
# Compare hashes
return new_hash_str == password_hash
except Exception:
return False
def generate_secure_token(length: int = 32) -> str:
"""Generate a cryptographically secure random token"""
return secrets.token_urlsafe(length)
def generate_api_key() -> str:
"""Generate a secure API key"""
return secrets.token_urlsafe(32)
def hash_api_key(api_key: str) -> str:
"""Hash an API key for storage"""
return hashlib.sha256(api_key.encode()).hexdigest()
def verify_api_key(api_key: str, hashed_key: str) -> bool:
"""Verify an API key against its hash"""
return hash_api_key(api_key) == hashed_key
def generate_session_id() -> str:
"""Generate a secure session ID"""
return secrets.token_hex(32)
def encode_base64(data: bytes) -> str:
"""Encode bytes to base64 string"""
return base64.urlsafe_b64encode(data).decode()
def decode_base64(data: str) -> bytes:
"""Decode base64 string to bytes"""
return base64.urlsafe_b64decode(data.encode())

359
backend/src/utils/email.py Normal file
View File

@ -0,0 +1,359 @@
"""
Email Service Utilities
This module provides email sending functionality for the application.
"""
import smtplib
import ssl
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional, List, Dict, Any
from pathlib import Path
import logging
from jinja2 import Template, Environment, FileSystemLoader
logger = logging.getLogger(__name__)
class EmailService:
"""Service for sending emails"""
def __init__(self, settings):
self.settings = settings
self.smtp_host = settings.email.smtp_host
self.smtp_port = settings.email.smtp_port
self.smtp_tls = settings.email.smtp_tls
self.smtp_ssl = settings.email.smtp_ssl
self.smtp_username = settings.email.smtp_username
self.smtp_password = settings.email.smtp_password
self.from_email = settings.email.from_email
self.from_name = settings.email.from_name
# Setup Jinja2 for email templates
template_dir = Path(__file__).parent.parent / "templates" / "emails"
if template_dir.exists():
self.jinja_env = Environment(loader=FileSystemLoader(str(template_dir)))
else:
self.jinja_env = Environment()
async def send_email(
self,
to_email: str,
subject: str,
html_content: str,
text_content: Optional[str] = None,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
attachments: Optional[List[Dict[str, Any]]] = None
) -> bool:
"""Send an email"""
try:
# Create message
message = MIMEMultipart("alternative")
message["Subject"] = subject
message["From"] = f"{self.from_name} <{self.from_email}>"
message["To"] = to_email
if cc:
message["Cc"] = ", ".join(cc)
if bcc:
message["Bcc"] = ", ".join(bcc)
# Add text and HTML parts
if text_content:
text_part = MIMEText(text_content, "plain")
message.attach(text_part)
html_part = MIMEText(html_content, "html")
message.attach(html_part)
# Add attachments if provided
if attachments:
for attachment in attachments:
# attachment should have 'content', 'filename', and optional 'content_type'
pass # Implementation would go here
# Send email
if self.smtp_ssl:
# SSL connection
context = ssl.create_default_context()
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=context) as server:
if self.smtp_username and self.smtp_password:
server.login(self.smtp_username, self.smtp_password)
recipients = [to_email]
if cc:
recipients.extend(cc)
if bcc:
recipients.extend(bcc)
server.sendmail(self.from_email, recipients, message.as_string())
else:
# TLS connection
with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
if self.smtp_tls:
server.starttls()
if self.smtp_username and self.smtp_password:
server.login(self.smtp_username, self.smtp_password)
recipients = [to_email]
if cc:
recipients.extend(cc)
if bcc:
recipients.extend(bcc)
server.sendmail(self.from_email, recipients, message.as_string())
logger.info(f"Email sent successfully to {to_email}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
return False
async def send_verification_email(
self,
to_email: str,
user_name: str,
verification_url: str
) -> bool:
"""Send email verification link"""
subject = self.settings.email.verification_subject
# HTML content
html_content = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2c3e50;">E-Mail-Verifizierung</h2>
<p>Hallo {user_name},</p>
<p>Vielen Dank für Ihre Registrierung. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{verification_url}"
style="display: inline-block; padding: 12px 24px; background-color: #3498db; color: white; text-decoration: none; border-radius: 5px;">
E-Mail verifizieren
</a>
</div>
<p>Oder kopieren Sie diesen Link in Ihren Browser:</p>
<p style="word-break: break-all; background: #f4f4f4; padding: 10px; border-radius: 3px;">
{verification_url}
</p>
<p>Dieser Link ist 24 Stunden gültig.</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
<p style="font-size: 12px; color: #666;">
Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.
</p>
</div>
</body>
</html>
"""
# Text content
text_content = f"""
Hallo {user_name},
Vielen Dank für Ihre Registrierung. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken:
{verification_url}
Dieser Link ist 24 Stunden gültig.
Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.
"""
return await self.send_email(to_email, subject, html_content, text_content)
async def send_magic_link_email(
self,
to_email: str,
user_name: str,
login_url: str
) -> bool:
"""Send magic link for login"""
subject = self.settings.email.magic_link_subject
# HTML content
html_content = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2c3e50;">Anmeldung bei STUPA</h2>
<p>Hallo {user_name},</p>
<p>Sie haben eine Anmeldung angefordert. Klicken Sie auf den folgenden Link, um sich anzumelden:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{login_url}"
style="display: inline-block; padding: 12px 24px; background-color: #27ae60; color: white; text-decoration: none; border-radius: 5px;">
Jetzt anmelden
</a>
</div>
<p>Oder kopieren Sie diesen Link in Ihren Browser:</p>
<p style="word-break: break-all; background: #f4f4f4; padding: 10px; border-radius: 3px;">
{login_url}
</p>
<p>Dieser Link ist 15 Minuten gültig.</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
<p style="font-size: 12px; color: #666;">
Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie bitte diese E-Mail.
</p>
</div>
</body>
</html>
"""
# Text content
text_content = f"""
Hallo {user_name},
Sie haben eine Anmeldung angefordert. Klicken Sie auf den folgenden Link, um sich anzumelden:
{login_url}
Dieser Link ist 15 Minuten gültig.
Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie bitte diese E-Mail.
"""
return await self.send_email(to_email, subject, html_content, text_content)
async def send_application_status_email(
self,
to_email: str,
user_name: str,
application_id: str,
old_status: str,
new_status: str,
comment: Optional[str] = None,
application_url: Optional[str] = None
) -> bool:
"""Send application status update email"""
subject = f"{self.settings.email.application_notification_subject} - Antrag {application_id}"
status_translations = {
"beantragt": "Beantragt",
"bearbeitung_gesperrt": "In Bearbeitung",
"zu_pruefen": "Wird geprüft",
"zur_abstimmung": "Zur Abstimmung",
"genehmigt": "Genehmigt",
"abgelehnt": "Abgelehnt"
}
old_status_display = status_translations.get(old_status, old_status)
new_status_display = status_translations.get(new_status, new_status)
# HTML content
html_content = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2c3e50;">Status-Update zu Ihrem Antrag</h2>
<p>Hallo {user_name},</p>
<p>Der Status Ihres Antrags <strong>{application_id}</strong> wurde aktualisiert:</p>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Vorheriger Status:</strong> {old_status_display}</p>
<p><strong>Neuer Status:</strong> <span style="color: #27ae60; font-weight: bold;">{new_status_display}</span></p>
</div>
"""
if comment:
html_content += f"""
<div style="background: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Kommentar:</strong></p>
<p>{comment}</p>
</div>
"""
if application_url:
html_content += f"""
<div style="text-align: center; margin: 30px 0;">
<a href="{application_url}"
style="display: inline-block; padding: 12px 24px; background-color: #3498db; color: white; text-decoration: none; border-radius: 5px;">
Antrag anzeigen
</a>
</div>
"""
html_content += """
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
<p style="font-size: 12px; color: #666;">
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.
</p>
</div>
</body>
</html>
"""
# Text content
text_content = f"""
Hallo {user_name},
Der Status Ihres Antrags {application_id} wurde aktualisiert:
Vorheriger Status: {old_status_display}
Neuer Status: {new_status_display}
"""
if comment:
text_content += f"\n\nKommentar:\n{comment}"
if application_url:
text_content += f"\n\nAntrag anzeigen: {application_url}"
text_content += "\n\nDiese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail."
return await self.send_email(to_email, subject, html_content, text_content)
async def send_review_request_email(
self,
to_email: str,
reviewer_name: str,
application_id: str,
applicant_name: str,
review_type: str,
review_url: str
) -> bool:
"""Send review request to reviewers"""
review_type_display = {
"budget": "Haushaltsbeauftragte(r)",
"finance": "Finanzreferent"
}.get(review_type, review_type)
subject = f"Prüfauftrag: Antrag {application_id} - {review_type_display}"
# HTML content
html_content = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2c3e50;">Prüfauftrag</h2>
<p>Hallo {reviewer_name},</p>
<p>Es liegt ein neuer Antrag zur Prüfung vor:</p>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Antragsnummer:</strong> {application_id}</p>
<p><strong>Antragsteller:</strong> {applicant_name}</p>
<p><strong>Ihre Rolle:</strong> {review_type_display}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{review_url}"
style="display: inline-block; padding: 12px 24px; background-color: #e74c3c; color: white; text-decoration: none; border-radius: 5px;">
Antrag prüfen
</a>
</div>
<p>Bitte prüfen Sie den Antrag zeitnah.</p>
</div>
</body>
</html>
"""
# Text content
text_content = f"""
Hallo {reviewer_name},
Es liegt ein neuer Antrag zur Prüfung vor:
Antragsnummer: {application_id}
Antragsteller: {applicant_name}
Ihre Rolle: {review_type_display}
Antrag prüfen: {review_url}
Bitte prüfen Sie den Antrag zeitnah.
"""
return await self.send_email(to_email, subject, html_content, text_content)

View File

@ -0,0 +1,348 @@
"""
File Storage Service
This module provides file storage and management utilities.
"""
import os
import shutil
import hashlib
from pathlib import Path
from typing import Optional, BinaryIO, Tuple
from datetime import datetime
import uuid
import mimetypes
import logging
logger = logging.getLogger(__name__)
class FileStorageService:
"""Service for managing file storage"""
def __init__(self, base_path: str):
"""Initialize file storage service
Args:
base_path: Base directory for file storage
"""
self.base_path = Path(base_path)
self.base_path.mkdir(parents=True, exist_ok=True)
def save_file(
self,
file_content: bytes,
filename: str,
subdirectory: Optional[str] = None,
generate_unique_name: bool = True
) -> Tuple[str, str]:
"""Save a file to storage
Args:
file_content: File content as bytes
filename: Original filename
subdirectory: Optional subdirectory within base path
generate_unique_name: Whether to generate a unique filename
Returns:
Tuple of (stored_path, file_hash)
"""
# Create subdirectory if specified
if subdirectory:
target_dir = self.base_path / subdirectory
else:
target_dir = self.base_path
target_dir.mkdir(parents=True, exist_ok=True)
# Generate unique filename if requested
if generate_unique_name:
file_ext = Path(filename).suffix
unique_id = uuid.uuid4().hex[:8]
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
stored_filename = f"{timestamp}_{unique_id}{file_ext}"
else:
stored_filename = filename
# Full path for storage
file_path = target_dir / stored_filename
# Calculate file hash
file_hash = hashlib.sha256(file_content).hexdigest()
# Save file
try:
with open(file_path, 'wb') as f:
f.write(file_content)
logger.info(f"File saved: {file_path}")
return str(file_path.relative_to(self.base_path)), file_hash
except Exception as e:
logger.error(f"Failed to save file {filename}: {e}")
raise
def read_file(self, file_path: str) -> bytes:
"""Read a file from storage
Args:
file_path: Path relative to base_path
Returns:
File content as bytes
"""
full_path = self.base_path / file_path
if not full_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
try:
with open(full_path, 'rb') as f:
return f.read()
except Exception as e:
logger.error(f"Failed to read file {file_path}: {e}")
raise
def delete_file(self, file_path: str) -> bool:
"""Delete a file from storage
Args:
file_path: Path relative to base_path
Returns:
True if file was deleted, False if file didn't exist
"""
full_path = self.base_path / file_path
if not full_path.exists():
return False
try:
os.remove(full_path)
logger.info(f"File deleted: {full_path}")
return True
except Exception as e:
logger.error(f"Failed to delete file {file_path}: {e}")
raise
def move_file(self, source_path: str, dest_path: str) -> str:
"""Move a file within storage
Args:
source_path: Source path relative to base_path
dest_path: Destination path relative to base_path
Returns:
New file path
"""
source_full = self.base_path / source_path
dest_full = self.base_path / dest_path
if not source_full.exists():
raise FileNotFoundError(f"Source file not found: {source_path}")
# Create destination directory if needed
dest_full.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.move(str(source_full), str(dest_full))
logger.info(f"File moved from {source_path} to {dest_path}")
return str(dest_full.relative_to(self.base_path))
except Exception as e:
logger.error(f"Failed to move file from {source_path} to {dest_path}: {e}")
raise
def copy_file(self, source_path: str, dest_path: str) -> str:
"""Copy a file within storage
Args:
source_path: Source path relative to base_path
dest_path: Destination path relative to base_path
Returns:
New file path
"""
source_full = self.base_path / source_path
dest_full = self.base_path / dest_path
if not source_full.exists():
raise FileNotFoundError(f"Source file not found: {source_path}")
# Create destination directory if needed
dest_full.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(str(source_full), str(dest_full))
logger.info(f"File copied from {source_path} to {dest_path}")
return str(dest_full.relative_to(self.base_path))
except Exception as e:
logger.error(f"Failed to copy file from {source_path} to {dest_path}: {e}")
raise
def file_exists(self, file_path: str) -> bool:
"""Check if a file exists
Args:
file_path: Path relative to base_path
Returns:
True if file exists, False otherwise
"""
full_path = self.base_path / file_path
return full_path.exists()
def get_file_info(self, file_path: str) -> dict:
"""Get information about a file
Args:
file_path: Path relative to base_path
Returns:
Dictionary with file information
"""
full_path = self.base_path / file_path
if not full_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
stat = full_path.stat()
# Get MIME type
mime_type, _ = mimetypes.guess_type(str(full_path))
return {
'path': file_path,
'filename': full_path.name,
'size': stat.st_size,
'mime_type': mime_type,
'created_at': datetime.fromtimestamp(stat.st_ctime),
'modified_at': datetime.fromtimestamp(stat.st_mtime),
'is_file': full_path.is_file(),
'is_dir': full_path.is_dir()
}
def list_files(self, subdirectory: Optional[str] = None, pattern: str = "*") -> list:
"""List files in storage
Args:
subdirectory: Optional subdirectory to list
pattern: Glob pattern for filtering files
Returns:
List of file paths relative to base_path
"""
if subdirectory:
target_dir = self.base_path / subdirectory
else:
target_dir = self.base_path
if not target_dir.exists():
return []
files = []
for file_path in target_dir.glob(pattern):
if file_path.is_file():
relative_path = file_path.relative_to(self.base_path)
files.append(str(relative_path))
return sorted(files)
def get_directory_size(self, subdirectory: Optional[str] = None) -> int:
"""Get total size of files in a directory
Args:
subdirectory: Optional subdirectory
Returns:
Total size in bytes
"""
if subdirectory:
target_dir = self.base_path / subdirectory
else:
target_dir = self.base_path
if not target_dir.exists():
return 0
total_size = 0
for file_path in target_dir.rglob('*'):
if file_path.is_file():
total_size += file_path.stat().st_size
return total_size
def cleanup_old_files(self, days: int = 30, subdirectory: Optional[str] = None) -> int:
"""Delete files older than specified days
Args:
days: Age threshold in days
subdirectory: Optional subdirectory to clean
Returns:
Number of files deleted
"""
if subdirectory:
target_dir = self.base_path / subdirectory
else:
target_dir = self.base_path
if not target_dir.exists():
return 0
cutoff_time = datetime.utcnow().timestamp() - (days * 24 * 60 * 60)
deleted_count = 0
for file_path in target_dir.rglob('*'):
if file_path.is_file():
if file_path.stat().st_mtime < cutoff_time:
try:
os.remove(file_path)
deleted_count += 1
logger.info(f"Deleted old file: {file_path}")
except Exception as e:
logger.error(f"Failed to delete old file {file_path}: {e}")
return deleted_count
def create_temp_file(self, content: bytes, suffix: str = "") -> str:
"""Create a temporary file
Args:
content: File content
suffix: File suffix/extension
Returns:
Path to temporary file
"""
temp_dir = self.base_path / "temp"
temp_dir.mkdir(parents=True, exist_ok=True)
temp_filename = f"tmp_{uuid.uuid4().hex}{suffix}"
temp_path = temp_dir / temp_filename
with open(temp_path, 'wb') as f:
f.write(content)
return str(temp_path.relative_to(self.base_path))
def get_file_hash(self, file_path: str, algorithm: str = 'sha256') -> str:
"""Calculate hash of a file
Args:
file_path: Path relative to base_path
algorithm: Hash algorithm to use
Returns:
Hex digest of file hash
"""
full_path = self.base_path / file_path
if not full_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
hash_obj = hashlib.new(algorithm)
with open(full_path, 'rb') as f:
while chunk := f.read(8192):
hash_obj.update(chunk)
return hash_obj.hexdigest()

View File

@ -0,0 +1,405 @@
"""
PDF utilities for template handling and field extraction
"""
import io
import os
from typing import List, Dict, Any, Optional
from PyPDF2 import PdfReader, PdfWriter
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
import fitz # PyMuPDF
from pathlib import Path
def extract_pdf_fields(pdf_content: bytes) -> List[str]:
"""
Extract form field names from a PDF template
Args:
pdf_content: PDF file content as bytes
Returns:
List of field names found in the PDF
"""
try:
pdf_reader = PdfReader(io.BytesIO(pdf_content))
fields = []
if '/AcroForm' in pdf_reader.trailer['/Root']:
form = pdf_reader.trailer['/Root']['/AcroForm']
if '/Fields' in form:
for field_ref in form['/Fields']:
field = field_ref.get_object()
if '/T' in field:
field_name = field['/T']
if isinstance(field_name, bytes):
field_name = field_name.decode('utf-8')
fields.append(str(field_name))
return fields
except Exception as e:
raise ValueError(f"Failed to extract PDF fields: {str(e)}")
def validate_pdf_template(pdf_content: bytes) -> Dict[str, Any]:
"""
Validate a PDF template and extract metadata
Args:
pdf_content: PDF file content as bytes
Returns:
Dictionary with validation results and metadata
"""
try:
pdf_reader = PdfReader(io.BytesIO(pdf_content))
result = {
"valid": True,
"page_count": len(pdf_reader.pages),
"has_form": False,
"fields": [],
"metadata": {}
}
# Extract metadata
if pdf_reader.metadata:
result["metadata"] = {
"title": pdf_reader.metadata.get('/Title', ''),
"author": pdf_reader.metadata.get('/Author', ''),
"subject": pdf_reader.metadata.get('/Subject', ''),
"creator": pdf_reader.metadata.get('/Creator', ''),
}
# Check for form fields
if '/AcroForm' in pdf_reader.trailer['/Root']:
result["has_form"] = True
result["fields"] = extract_pdf_fields(pdf_content)
return result
except Exception as e:
return {
"valid": False,
"error": str(e)
}
def fill_pdf_template(
template_content: bytes,
field_mapping: Dict[str, str],
field_data: Dict[str, Any],
output_path: Optional[str] = None
) -> bytes:
"""
Fill a PDF template with data
Args:
template_content: PDF template content as bytes
field_mapping: Mapping from PDF field names to data field IDs
field_data: Data to fill in the fields
output_path: Optional path to save the filled PDF
Returns:
Filled PDF content as bytes
"""
try:
# Read the template
pdf_reader = PdfReader(io.BytesIO(template_content))
pdf_writer = PdfWriter()
# Copy all pages and fill form fields
for page_num in range(len(pdf_reader.pages)):
page = pdf_reader.pages[page_num]
pdf_writer.add_page(page)
# Fill form fields if they exist
if '/AcroForm' in pdf_reader.trailer['/Root']:
pdf_writer.update_page_form_field_values(
pdf_writer.pages[0],
{pdf_field: str(field_data.get(data_field, ''))
for pdf_field, data_field in field_mapping.items()
if data_field in field_data}
)
# Write to bytes or file
output_buffer = io.BytesIO()
pdf_writer.write(output_buffer)
pdf_content = output_buffer.getvalue()
if output_path:
with open(output_path, 'wb') as f:
f.write(pdf_content)
return pdf_content
except Exception as e:
raise ValueError(f"Failed to fill PDF template: {str(e)}")
def create_pdf_from_data(
data: Dict[str, Any],
title: str = "Application",
output_path: Optional[str] = None
) -> bytes:
"""
Create a PDF document from application data (when no template is available)
Args:
data: Application data
title: Document title
output_path: Optional path to save the PDF
Returns:
PDF content as bytes
"""
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
# Set up fonts
try:
# Try to use a custom font if available
font_path = Path(__file__).parent.parent / "assets" / "fonts" / "Roboto-Regular.ttf"
if font_path.exists():
pdfmetrics.registerFont(TTFont('Roboto', str(font_path)))
c.setFont('Roboto', 12)
else:
c.setFont('Helvetica', 12)
except:
c.setFont('Helvetica', 12)
# Title
c.setFont('Helvetica-Bold', 16)
c.drawString(50, height - 50, title)
# Content
y_position = height - 100
c.setFont('Helvetica', 10)
for key, value in data.items():
if y_position < 100:
c.showPage()
y_position = height - 50
c.setFont('Helvetica', 10)
# Format key
display_key = key.replace('_', ' ').title()
# Handle different value types
if isinstance(value, (list, dict)):
c.setFont('Helvetica-Bold', 10)
c.drawString(50, y_position, f"{display_key}:")
y_position -= 15
c.setFont('Helvetica', 10)
if isinstance(value, list):
for item in value:
if y_position < 100:
c.showPage()
y_position = height - 50
c.drawString(70, y_position, f"{str(item)}")
y_position -= 15
else:
for sub_key, sub_value in value.items():
if y_position < 100:
c.showPage()
y_position = height - 50
c.drawString(70, y_position, f"{sub_key}: {str(sub_value)}")
y_position -= 15
else:
# Simple key-value pair
text = f"{display_key}: {str(value)}"
# Handle long text
if len(text) > 80:
lines = [text[i:i+80] for i in range(0, len(text), 80)]
for line in lines:
if y_position < 100:
c.showPage()
y_position = height - 50
c.drawString(50, y_position, line)
y_position -= 15
else:
c.drawString(50, y_position, text)
y_position -= 15
y_position -= 5 # Extra spacing between fields
# Footer
c.setFont('Helvetica-Oblique', 8)
c.drawString(50, 30, f"Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
c.save()
pdf_content = buffer.getvalue()
if output_path:
with open(output_path, 'wb') as f:
f.write(pdf_content)
return pdf_content
def merge_pdfs(pdf_contents: List[bytes], output_path: Optional[str] = None) -> bytes:
"""
Merge multiple PDF documents
Args:
pdf_contents: List of PDF contents as bytes
output_path: Optional path to save the merged PDF
Returns:
Merged PDF content as bytes
"""
pdf_writer = PdfWriter()
for pdf_content in pdf_contents:
pdf_reader = PdfReader(io.BytesIO(pdf_content))
for page in pdf_reader.pages:
pdf_writer.add_page(page)
output_buffer = io.BytesIO()
pdf_writer.write(output_buffer)
merged_content = output_buffer.getvalue()
if output_path:
with open(output_path, 'wb') as f:
f.write(merged_content)
return merged_content
def add_watermark_to_pdf(
pdf_content: bytes,
watermark_text: str,
output_path: Optional[str] = None
) -> bytes:
"""
Add a watermark to a PDF document
Args:
pdf_content: PDF content as bytes
watermark_text: Text to use as watermark
output_path: Optional path to save the watermarked PDF
Returns:
Watermarked PDF content as bytes
"""
# Create watermark PDF
watermark_buffer = io.BytesIO()
c = canvas.Canvas(watermark_buffer, pagesize=A4)
width, height = A4
c.setFont('Helvetica', 50)
c.setFillAlpha(0.3)
c.saveState()
c.translate(width/2, height/2)
c.rotate(45)
c.drawCentredString(0, 0, watermark_text)
c.restoreState()
c.save()
# Read original and watermark PDFs
pdf_reader = PdfReader(io.BytesIO(pdf_content))
watermark_reader = PdfReader(watermark_buffer)
watermark_page = watermark_reader.pages[0]
# Apply watermark to all pages
pdf_writer = PdfWriter()
for page in pdf_reader.pages:
page.merge_page(watermark_page)
pdf_writer.add_page(page)
# Write output
output_buffer = io.BytesIO()
pdf_writer.write(output_buffer)
watermarked_content = output_buffer.getvalue()
if output_path:
with open(output_path, 'wb') as f:
f.write(watermarked_content)
return watermarked_content
def extract_text_from_pdf(pdf_content: bytes) -> str:
"""
Extract text content from a PDF
Args:
pdf_content: PDF content as bytes
Returns:
Extracted text as string
"""
try:
pdf_document = fitz.open(stream=pdf_content, filetype="pdf")
text = ""
for page_num in range(pdf_document.page_count):
page = pdf_document[page_num]
text += page.get_text()
pdf_document.close()
return text
except Exception as e:
# Fallback to PyPDF2
try:
pdf_reader = PdfReader(io.BytesIO(pdf_content))
text = ""
for page in pdf_reader.pages:
text += page.extract_text()
return text
except:
raise ValueError(f"Failed to extract text from PDF: {str(e)}")
def get_pdf_info(pdf_content: bytes) -> Dict[str, Any]:
"""
Get information about a PDF document
Args:
pdf_content: PDF content as bytes
Returns:
Dictionary with PDF information
"""
try:
pdf_reader = PdfReader(io.BytesIO(pdf_content))
info = {
"page_count": len(pdf_reader.pages),
"has_forms": '/AcroForm' in pdf_reader.trailer['/Root'],
"is_encrypted": pdf_reader.is_encrypted,
"metadata": {}
}
if pdf_reader.metadata:
info["metadata"] = {
"title": pdf_reader.metadata.get('/Title', ''),
"author": pdf_reader.metadata.get('/Author', ''),
"subject": pdf_reader.metadata.get('/Subject', ''),
"creator": pdf_reader.metadata.get('/Creator', ''),
"producer": pdf_reader.metadata.get('/Producer', ''),
"creation_date": str(pdf_reader.metadata.get('/CreationDate', '')),
"modification_date": str(pdf_reader.metadata.get('/ModDate', '')),
}
# Get page sizes
page_sizes = []
for page in pdf_reader.pages:
mediabox = page.mediabox
page_sizes.append({
"width": float(mediabox.width),
"height": float(mediabox.height)
})
info["page_sizes"] = page_sizes
# Get form fields if present
if info["has_forms"]:
info["form_fields"] = extract_pdf_fields(pdf_content)
return info
except Exception as e:
raise ValueError(f"Failed to get PDF info: {str(e)}")

View File

@ -0,0 +1,463 @@
"""
Field validation utilities for dynamic applications
"""
import re
from datetime import datetime
from typing import Any, Optional, Dict
from email_validator import validate_email, EmailNotValidError
from ..models.application_type import ApplicationField, FieldType
def validate_field_value(value: Any, field: ApplicationField) -> bool:
"""
Validate a field value against its definition and rules
Args:
value: The value to validate
field: The field definition
Returns:
True if valid
Raises:
ValueError: If validation fails
"""
# Check if required
if field.is_required and (value is None or value == ""):
raise ValueError(f"Field '{field.name}' is required")
# If not required and empty, that's okay
if value is None or value == "":
return True
# Type-specific validation
if field.field_type == FieldType.TEXT_SHORT:
return validate_text_short(value, field)
elif field.field_type == FieldType.TEXT_LONG:
return validate_text_long(value, field)
elif field.field_type == FieldType.OPTIONS:
return validate_options(value, field)
elif field.field_type == FieldType.YESNO:
return validate_yesno(value, field)
elif field.field_type == FieldType.MAIL:
return validate_mail(value, field)
elif field.field_type == FieldType.DATE:
return validate_date(value, field)
elif field.field_type == FieldType.DATETIME:
return validate_datetime(value, field)
elif field.field_type == FieldType.AMOUNT:
return validate_amount(value, field)
elif field.field_type == FieldType.CURRENCY_EUR:
return validate_currency_eur(value, field)
elif field.field_type == FieldType.NUMBER:
return validate_number(value, field)
elif field.field_type == FieldType.PHONE:
return validate_phone(value, field)
elif field.field_type == FieldType.URL:
return validate_url(value, field)
elif field.field_type == FieldType.CHECKBOX:
return validate_checkbox(value, field)
elif field.field_type == FieldType.RADIO:
return validate_radio(value, field)
elif field.field_type == FieldType.SELECT:
return validate_select(value, field)
elif field.field_type == FieldType.MULTISELECT:
return validate_multiselect(value, field)
return True
def validate_text_short(value: Any, field: ApplicationField) -> bool:
"""Validate short text field"""
if not isinstance(value, str):
raise ValueError(f"Field '{field.name}' must be a string")
rules = field.validation_rules or {}
# Check max length (default 255 for short text)
max_length = rules.get("max_length", 255)
if len(value) > max_length:
raise ValueError(f"Field '{field.name}' exceeds maximum length of {max_length}")
# Check min length
min_length = rules.get("min_length")
if min_length and len(value) < min_length:
raise ValueError(f"Field '{field.name}' must be at least {min_length} characters")
# Check pattern
pattern = rules.get("pattern")
if pattern and not re.match(pattern, value):
raise ValueError(f"Field '{field.name}' does not match required pattern")
return True
def validate_text_long(value: Any, field: ApplicationField) -> bool:
"""Validate long text field"""
if not isinstance(value, str):
raise ValueError(f"Field '{field.name}' must be a string")
rules = field.validation_rules or {}
# Check max length (default 10000 for long text)
max_length = rules.get("max_length", 10000)
if len(value) > max_length:
raise ValueError(f"Field '{field.name}' exceeds maximum length of {max_length}")
# Check min length
min_length = rules.get("min_length")
if min_length and len(value) < min_length:
raise ValueError(f"Field '{field.name}' must be at least {min_length} characters")
return True
def validate_options(value: Any, field: ApplicationField) -> bool:
"""Validate options field"""
if not field.options:
raise ValueError(f"Field '{field.name}' has no options defined")
if value not in field.options:
raise ValueError(f"Field '{field.name}' value must be one of: {', '.join(field.options)}")
return True
def validate_yesno(value: Any, field: ApplicationField) -> bool:
"""Validate yes/no field"""
if not isinstance(value, bool):
# Also accept "true"/"false", "yes"/"no", 1/0
if isinstance(value, str):
if value.lower() in ["true", "yes", "1"]:
return True
elif value.lower() in ["false", "no", "0"]:
return True
else:
raise ValueError(f"Field '{field.name}' must be yes/no")
elif isinstance(value, int):
if value not in [0, 1]:
raise ValueError(f"Field '{field.name}' must be yes/no")
else:
raise ValueError(f"Field '{field.name}' must be yes/no")
return True
def validate_mail(value: Any, field: ApplicationField) -> bool:
"""Validate email field"""
if not isinstance(value, str):
raise ValueError(f"Field '{field.name}' must be a string")
try:
validate_email(value)
except EmailNotValidError as e:
raise ValueError(f"Field '{field.name}' is not a valid email address: {str(e)}")
return True
def validate_date(value: Any, field: ApplicationField) -> bool:
"""Validate date field"""
if not isinstance(value, str):
raise ValueError(f"Field '{field.name}' must be a date string")
# Accept various date formats
date_formats = [
"%Y-%m-%d",
"%d.%m.%Y",
"%d/%m/%Y",
"%Y/%m/%d"
]
rules = field.validation_rules or {}
custom_format = rules.get("date_format")
if custom_format:
date_formats = [custom_format] + date_formats
parsed_date = None
for fmt in date_formats:
try:
parsed_date = datetime.strptime(value, fmt)
break
except ValueError:
continue
if not parsed_date:
raise ValueError(f"Field '{field.name}' is not a valid date")
# Check min/max dates
min_date = rules.get("min_date")
if min_date:
min_dt = datetime.strptime(min_date, "%Y-%m-%d")
if parsed_date.date() < min_dt.date():
raise ValueError(f"Field '{field.name}' must be after {min_date}")
max_date = rules.get("max_date")
if max_date:
max_dt = datetime.strptime(max_date, "%Y-%m-%d")
if parsed_date.date() > max_dt.date():
raise ValueError(f"Field '{field.name}' must be before {max_date}")
return True
def validate_datetime(value: Any, field: ApplicationField) -> bool:
"""Validate datetime field"""
if not isinstance(value, str):
raise ValueError(f"Field '{field.name}' must be a datetime string")
# Accept ISO format primarily
try:
datetime.fromisoformat(value)
except ValueError:
raise ValueError(f"Field '{field.name}' is not a valid datetime (use ISO format)")
return True
def validate_amount(value: Any, field: ApplicationField) -> bool:
"""Validate amount field"""
try:
amount = float(value)
except (TypeError, ValueError):
raise ValueError(f"Field '{field.name}' must be a number")
rules = field.validation_rules or {}
# Check min/max
min_amount = rules.get("min", 0)
if amount < min_amount:
raise ValueError(f"Field '{field.name}' must be at least {min_amount}")
max_amount = rules.get("max")
if max_amount and amount > max_amount:
raise ValueError(f"Field '{field.name}' must not exceed {max_amount}")
# Check decimal places
decimal_places = rules.get("decimal_places", 2)
if decimal_places is not None:
decimal_str = str(amount).split('.')
if len(decimal_str) > 1 and len(decimal_str[1]) > decimal_places:
raise ValueError(f"Field '{field.name}' must have at most {decimal_places} decimal places")
return True
def validate_currency_eur(value: Any, field: ApplicationField) -> bool:
"""Validate EUR currency field"""
# Same as amount but with EUR-specific validation
result = validate_amount(value, field)
# Additional EUR-specific checks if needed
try:
amount = float(value)
if amount < 0:
raise ValueError(f"Field '{field.name}' cannot be negative")
except (TypeError, ValueError):
pass # Already handled in validate_amount
return result
def validate_number(value: Any, field: ApplicationField) -> bool:
"""Validate number field"""
rules = field.validation_rules or {}
integer_only = rules.get("integer_only", False)
if integer_only:
try:
num = int(value)
except (TypeError, ValueError):
raise ValueError(f"Field '{field.name}' must be an integer")
else:
try:
num = float(value)
except (TypeError, ValueError):
raise ValueError(f"Field '{field.name}' must be a number")
# Check min/max
min_val = rules.get("min")
if min_val is not None and num < min_val:
raise ValueError(f"Field '{field.name}' must be at least {min_val}")
max_val = rules.get("max")
if max_val is not None and num > max_val:
raise ValueError(f"Field '{field.name}' must not exceed {max_val}")
return True
def validate_phone(value: Any, field: ApplicationField) -> bool:
"""Validate phone number field"""
if not isinstance(value, str):
raise ValueError(f"Field '{field.name}' must be a string")
# Remove common formatting characters
cleaned = re.sub(r'[\s\-\(\)\.]+', '', value)
# Check if it looks like a phone number
if not re.match(r'^\+?[0-9]{7,15}$', cleaned):
raise ValueError(f"Field '{field.name}' is not a valid phone number")
return True
def validate_url(value: Any, field: ApplicationField) -> bool:
"""Validate URL field"""
if not isinstance(value, str):
raise ValueError(f"Field '{field.name}' must be a string")
# Basic URL validation
url_pattern = re.compile(
r'^https?://' # http:// or https://
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain...
r'localhost|' # localhost...
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
r'(?::\d+)?' # optional port
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
if not url_pattern.match(value):
raise ValueError(f"Field '{field.name}' is not a valid URL")
return True
def validate_checkbox(value: Any, field: ApplicationField) -> bool:
"""Validate checkbox field"""
return validate_yesno(value, field)
def validate_radio(value: Any, field: ApplicationField) -> bool:
"""Validate radio field"""
return validate_options(value, field)
def validate_select(value: Any, field: ApplicationField) -> bool:
"""Validate select field"""
return validate_options(value, field)
def validate_multiselect(value: Any, field: ApplicationField) -> bool:
"""Validate multiselect field"""
if not isinstance(value, list):
raise ValueError(f"Field '{field.name}' must be a list")
if not field.options:
raise ValueError(f"Field '{field.name}' has no options defined")
for item in value:
if item not in field.options:
raise ValueError(f"Field '{field.name}' contains invalid option: {item}")
rules = field.validation_rules or {}
# Check min/max selections
min_selections = rules.get("min_selections")
if min_selections and len(value) < min_selections:
raise ValueError(f"Field '{field.name}' requires at least {min_selections} selections")
max_selections = rules.get("max_selections")
if max_selections and len(value) > max_selections:
raise ValueError(f"Field '{field.name}' allows at most {max_selections} selections")
return True
def validate_display_conditions(
field: ApplicationField,
form_data: Dict[str, Any]
) -> bool:
"""
Check if a field should be displayed based on conditions
Args:
field: The field to check
form_data: Current form data
Returns:
True if field should be displayed
"""
if not field.display_conditions:
return True
conditions = field.display_conditions
# Support simple conditions like:
# {"field": "other_field", "operator": "equals", "value": "some_value"}
# or {"and": [...], "or": [...]} for complex conditions
return evaluate_condition(conditions, form_data)
def evaluate_condition(condition: Dict[str, Any], form_data: Dict[str, Any]) -> bool:
"""
Evaluate a display condition
Args:
condition: Condition definition
form_data: Current form data
Returns:
True if condition is met
"""
if "and" in condition:
# All conditions must be true
return all(evaluate_condition(c, form_data) for c in condition["and"])
if "or" in condition:
# At least one condition must be true
return any(evaluate_condition(c, form_data) for c in condition["or"])
if "not" in condition:
# Negate the condition
return not evaluate_condition(condition["not"], form_data)
# Simple condition
if "field" in condition:
field_id = condition["field"]
operator = condition.get("operator", "equals")
expected_value = condition.get("value")
actual_value = form_data.get(field_id)
if operator == "equals":
return actual_value == expected_value
elif operator == "not_equals":
return actual_value != expected_value
elif operator == "in":
return actual_value in expected_value
elif operator == "not_in":
return actual_value not in expected_value
elif operator == "contains":
return expected_value in str(actual_value)
elif operator == "not_contains":
return expected_value not in str(actual_value)
elif operator == "empty":
return not actual_value
elif operator == "not_empty":
return bool(actual_value)
elif operator == "greater_than":
try:
return float(actual_value) > float(expected_value)
except (TypeError, ValueError):
return False
elif operator == "less_than":
try:
return float(actual_value) < float(expected_value)
except (TypeError, ValueError):
return False
elif operator == "greater_or_equal":
try:
return float(actual_value) >= float(expected_value)
except (TypeError, ValueError):
return False
elif operator == "less_or_equal":
try:
return float(actual_value) <= float(expected_value)
except (TypeError, ValueError):
return False
# Unknown condition format, default to true
return True

View File

@ -25,7 +25,7 @@ services:
timeout: 5s
retries: 6
ports:
- "3306:3306"
- "3307:3306"
volumes:
- db_data:/var/lib/mysql
networks:
@ -188,7 +188,7 @@ services:
ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: pepa-linha-dark
ports:
- "8080:8080"
- "8081:8080"
networks:
- stupa_network

View File

@ -0,0 +1,478 @@
// API client for dynamic application system
import axios, { AxiosInstance, AxiosError } from 'axios';
import {
ApplicationType,
DynamicApplication,
ApplicationListItem,
CreateApplicationRequest,
UpdateApplicationRequest,
CreateApplicationResponse,
StatusTransitionRequest,
ApplicationHistoryEntry,
ApplicationApproval,
ApplicationSearchParams,
ApplicationTypeCreateRequest,
ApplicationTypeUpdateRequest,
PDFTemplateUploadResponse,
ApiResponse,
ApiError,
User,
Session,
LoginRequest,
} from '../types/dynamic';
class DynamicApiClient {
private client: AxiosInstance;
private baseURL: string;
private accessToken: string | null = null;
constructor(baseURL: string = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000') {
this.baseURL = baseURL;
this.client = axios.create({
baseURL: `${baseURL}/api`,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});
// Request interceptor to add auth token
this.client.interceptors.request.use(
(config) => {
const token = this.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
// Try to refresh token
const refreshed = await this.refreshToken();
if (refreshed) {
// Retry original request
const originalRequest = error.config;
if (originalRequest) {
return this.client(originalRequest);
}
} else {
// Clear token and redirect to login
this.clearAuth();
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
}
// Auth methods
private getAccessToken(): string | null {
if (this.accessToken) {
return this.accessToken;
}
const stored = localStorage.getItem('access_token');
if (stored) {
this.accessToken = stored;
}
return this.accessToken;
}
private setAccessToken(token: string): void {
this.accessToken = token;
localStorage.setItem('access_token', token);
}
private clearAuth(): void {
this.accessToken = null;
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
}
private async refreshToken(): Promise<boolean> {
try {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) return false;
const response = await axios.post(`${this.baseURL}/api/auth/refresh`, {
refresh_token: refreshToken,
});
if (response.data.access_token) {
this.setAccessToken(response.data.access_token);
if (response.data.refresh_token) {
localStorage.setItem('refresh_token', response.data.refresh_token);
}
return true;
}
return false;
} catch (error) {
return false;
}
}
// Authentication
async login(request: LoginRequest): Promise<ApiResponse<Session>> {
try {
const response = await this.client.post<Session>('/auth/login', request);
const session = response.data;
this.setAccessToken(session.access_token);
if (session.refresh_token) {
localStorage.setItem('refresh_token', session.refresh_token);
}
localStorage.setItem('user', JSON.stringify(session.user));
return { success: true, data: session };
} catch (error) {
return this.handleError(error);
}
}
async logout(): Promise<void> {
try {
await this.client.post('/auth/logout');
} finally {
this.clearAuth();
}
}
async getCurrentUser(): Promise<ApiResponse<User>> {
try {
const response = await this.client.get<User>('/auth/me');
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
// Application Types
async getApplicationTypes(includeInactive = false): Promise<ApiResponse<ApplicationType[]>> {
try {
const response = await this.client.get<ApplicationType[]>('/application-types', {
params: { include_inactive: includeInactive },
});
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async getApplicationType(typeId: string): Promise<ApiResponse<ApplicationType>> {
try {
const response = await this.client.get<ApplicationType>(`/application-types/${typeId}`);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async createApplicationType(
data: ApplicationTypeCreateRequest,
pdfTemplate?: File
): Promise<ApiResponse<ApplicationType>> {
try {
const formData = new FormData();
formData.append('type_data', JSON.stringify(data));
if (pdfTemplate) {
formData.append('pdf_template', pdfTemplate);
}
const response = await this.client.post<ApplicationType>('/application-types', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async updateApplicationType(
typeId: string,
data: ApplicationTypeUpdateRequest
): Promise<ApiResponse<ApplicationType>> {
try {
const response = await this.client.put<ApplicationType>(
`/application-types/${typeId}`,
data
);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async uploadPdfTemplate(
typeId: string,
pdfFile: File
): Promise<ApiResponse<PDFTemplateUploadResponse>> {
try {
const formData = new FormData();
formData.append('pdf_template', pdfFile);
const response = await this.client.post<PDFTemplateUploadResponse>(
`/application-types/${typeId}/pdf-template`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async deleteApplicationType(typeId: string): Promise<ApiResponse<{ message: string }>> {
try {
const response = await this.client.delete<{ message: string }>(
`/application-types/${typeId}`
);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
// Applications
async getApplications(params?: ApplicationSearchParams): Promise<ApiResponse<ApplicationListItem[]>> {
try {
const response = await this.client.get<ApplicationListItem[]>('/applications', { params });
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async getApplication(
applicationId: string,
accessKey?: string
): Promise<ApiResponse<DynamicApplication>> {
try {
const params = accessKey ? { access_key: accessKey } : {};
const response = await this.client.get<DynamicApplication>(
`/applications/${applicationId}`,
{ params }
);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async createApplication(
data: CreateApplicationRequest
): Promise<ApiResponse<CreateApplicationResponse>> {
try {
const response = await this.client.post<CreateApplicationResponse>('/applications', data);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async updateApplication(
applicationId: string,
data: UpdateApplicationRequest,
accessKey?: string
): Promise<ApiResponse<DynamicApplication>> {
try {
const params = accessKey ? { access_key: accessKey } : {};
const response = await this.client.put<DynamicApplication>(
`/applications/${applicationId}`,
data,
{ params }
);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async submitApplication(
applicationId: string,
accessKey?: string
): Promise<ApiResponse<{ message: string; new_status: string }>> {
try {
const params = accessKey ? { access_key: accessKey } : {};
const response = await this.client.post<{ message: string; new_status: string }>(
`/applications/${applicationId}/submit`,
{},
{ params }
);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async transitionApplicationStatus(
applicationId: string,
data: StatusTransitionRequest
): Promise<ApiResponse<{ message: string; new_status: string; new_status_name: string }>> {
try {
const response = await this.client.post<{
message: string;
new_status: string;
new_status_name: string;
}>(`/applications/${applicationId}/transition`, data);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async approveApplication(
applicationId: string,
approval: ApplicationApproval
): Promise<ApiResponse<{ message: string; role: string; decision: string }>> {
try {
const response = await this.client.post<{
message: string;
role: string;
decision: string;
}>(`/applications/${applicationId}/approve`, approval);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async getApplicationHistory(
applicationId: string
): Promise<ApiResponse<ApplicationHistoryEntry[]>> {
try {
const response = await this.client.get<ApplicationHistoryEntry[]>(
`/applications/${applicationId}/history`
);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async generateApplicationPdf(
applicationId: string
): Promise<ApiResponse<{ message: string; pdf_path: string }>> {
try {
const response = await this.client.post<{ message: string; pdf_path: string }>(
`/applications/${applicationId}/generate-pdf`
);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async downloadApplicationPdf(applicationId: string): Promise<Blob> {
const response = await this.client.get(`/applications/${applicationId}/pdf`, {
responseType: 'blob',
});
return response.data;
}
// Error handling
private handleError(error: any): ApiResponse<any> {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<ApiError>;
if (axiosError.response?.data) {
return {
success: false,
error: axiosError.response.data,
};
}
return {
success: false,
error: {
detail: axiosError.message || 'An unknown error occurred',
},
};
}
return {
success: false,
error: {
detail: 'An unexpected error occurred',
},
};
}
// Utility methods
async uploadFile(file: File, endpoint: string): Promise<ApiResponse<{ url: string }>> {
try {
const formData = new FormData();
formData.append('file', file);
const response = await this.client.post<{ url: string }>(endpoint, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async exportApplications(
applicationIds: string[],
format: 'pdf' | 'json' | 'csv'
): Promise<Blob> {
const response = await this.client.post(
'/applications/export',
{ application_ids: applicationIds, format },
{ responseType: 'blob' }
);
return response.data;
}
// Public access methods (no auth required)
async getPublicApplication(
applicationId: string,
accessKey: string
): Promise<ApiResponse<DynamicApplication>> {
try {
const response = await axios.get<DynamicApplication>(
`${this.baseURL}/api/public/applications/${applicationId}`,
{ params: { key: accessKey } }
);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
async updatePublicApplication(
applicationId: string,
accessKey: string,
data: UpdateApplicationRequest
): Promise<ApiResponse<DynamicApplication>> {
try {
const response = await axios.put<DynamicApplication>(
`${this.baseURL}/api/public/applications/${applicationId}`,
data,
{ params: { key: accessKey } }
);
return { success: true, data: response.data };
} catch (error) {
return this.handleError(error);
}
}
}
// Export singleton instance
export const dynamicApiClient = new DynamicApiClient();
// Export class for testing or multiple instances
export default DynamicApiClient;

View File

@ -0,0 +1,527 @@
// Dynamic Application System Types
// Field Types
export type FieldType =
| "text_short"
| "text_long"
| "options"
| "yesno"
| "mail"
| "date"
| "datetime"
| "amount"
| "currency_eur"
| "number"
| "file"
| "signature"
| "phone"
| "url"
| "checkbox"
| "radio"
| "select"
| "multiselect";
// Transition Trigger Types
export type TransitionTriggerType =
| "user_approval"
| "applicant_action"
| "deadline_expired"
| "time_elapsed"
| "condition_met"
| "automatic";
// Field Definition
export interface FieldDefinition {
field_id: string;
field_type: FieldType;
name: string;
label?: string;
description?: string;
field_order: number;
is_required: boolean;
is_readonly: boolean;
is_hidden: boolean;
options?: string[];
default_value?: string;
validation_rules?: ValidationRules;
display_conditions?: DisplayCondition;
placeholder?: string;
section?: string;
}
// Validation Rules
export interface ValidationRules {
min?: number;
max?: number;
min_length?: number;
max_length?: number;
pattern?: string;
date_format?: string;
min_date?: string;
max_date?: string;
decimal_places?: number;
integer_only?: boolean;
min_selections?: number;
max_selections?: number;
}
// Display Conditions
export interface DisplayCondition {
field?: string;
operator?: ConditionOperator;
value?: any;
and?: DisplayCondition[];
or?: DisplayCondition[];
not?: DisplayCondition;
}
export type ConditionOperator =
| "equals"
| "not_equals"
| "in"
| "not_in"
| "contains"
| "not_contains"
| "empty"
| "not_empty"
| "greater_than"
| "less_than"
| "greater_or_equal"
| "less_or_equal";
// Status Definition
export interface StatusDefinition {
status_id: string;
name: string;
description?: string;
is_editable: boolean;
color?: string;
icon?: string;
display_order: number;
is_initial: boolean;
is_final: boolean;
is_cancelled: boolean;
send_notification: boolean;
notification_template?: string;
}
// Transition Definition
export interface TransitionDefinition {
from_status_id: string;
to_status_id: string;
name: string;
trigger_type: TransitionTriggerType;
trigger_config: TriggerConfig;
conditions?: Record<string, any>;
actions?: TransitionAction[];
priority: number;
is_active: boolean;
}
// Trigger Configuration
export interface TriggerConfig {
role?: string;
required_approvals?: number;
deadline_field?: string;
time_span_hours?: number;
button_label?: string;
button_style?: string;
}
// Transition Actions
export interface TransitionAction {
type: string;
config: Record<string, any>;
}
// Application Type
export interface ApplicationType {
id: number;
type_id: string;
name: string;
description?: string;
is_active: boolean;
is_public: boolean;
allowed_roles: string[];
max_cost_positions: number;
max_comparison_offers: number;
version: string;
usage_count: number;
pdf_template_filename?: string;
fields: FieldDefinition[];
statuses: StatusDefinition[];
transitions: TransitionDefinition[];
created_at: string;
updated_at: string;
}
// Cost Position
export interface CostPosition {
description: string;
amount: number;
category?: string;
notes?: string;
}
// Comparison Offer
export interface ComparisonOffer {
vendor: string;
description: string;
amount: number;
selected: boolean;
notes?: string;
}
// Dynamic Application
export interface DynamicApplication {
id: number;
application_id: string;
application_type_id: number;
type_name: string;
email: string;
status_id: string;
status_name: string;
title: string;
first_name?: string;
last_name?: string;
total_amount: number;
field_data: Record<string, any>;
cost_positions: CostPosition[];
comparison_offers: ComparisonOffer[];
submitted_at?: string;
status_changed_at?: string;
created_at: string;
updated_at: string;
can_edit: boolean;
available_actions: string[];
}
// Application List Item
export interface ApplicationListItem {
id: number;
application_id: string;
type_name: string;
title: string;
email: string;
status_id: string;
status_name: string;
total_amount: number;
submitted_at?: string;
created_at: string;
}
// Application History Entry
export interface ApplicationHistoryEntry {
id: number;
action: string;
comment?: string;
field_changes?: Record<string, { old: any; new: any }>;
user_id?: number;
created_at: string;
}
// Application Approval
export interface ApplicationApproval {
decision: "approve" | "reject" | "abstain";
comment?: string;
}
// Create Application Request
export interface CreateApplicationRequest {
application_type_id: string;
title: string;
field_data: Record<string, any>;
cost_positions?: CostPosition[];
comparison_offers?: ComparisonOffer[];
}
// Update Application Request
export interface UpdateApplicationRequest {
title?: string;
field_data?: Record<string, any>;
cost_positions?: CostPosition[];
comparison_offers?: ComparisonOffer[];
}
// Create Application Response
export interface CreateApplicationResponse {
application_id: string;
access_key: string;
access_url: string;
status: string;
}
// Status Transition Request
export interface StatusTransitionRequest {
new_status_id: string;
comment?: string;
trigger_data?: Record<string, any>;
}
// Application Type Create Request
export interface ApplicationTypeCreateRequest {
type_id: string;
name: string;
description?: string;
fields: FieldDefinition[];
statuses: StatusDefinition[];
transitions: TransitionDefinition[];
pdf_field_mapping?: Record<string, string>;
is_active?: boolean;
is_public?: boolean;
allowed_roles?: string[];
max_cost_positions?: number;
max_comparison_offers?: number;
}
// Application Type Update Request
export interface ApplicationTypeUpdateRequest {
name?: string;
description?: string;
is_active?: boolean;
is_public?: boolean;
allowed_roles?: string[];
max_cost_positions?: number;
max_comparison_offers?: number;
}
// Search Parameters
export interface ApplicationSearchParams {
type_id?: string;
status_id?: string;
email?: string;
search?: string;
submitted_after?: string;
submitted_before?: string;
limit?: number;
offset?: number;
}
// Form State for Dynamic Applications
export interface DynamicFormData {
// Common fields
email: string;
title: string;
first_name?: string;
last_name?: string;
// Dynamic fields
fields: Record<string, any>;
// Cost positions
cost_positions: CostPosition[];
// Comparison offers
comparison_offers: ComparisonOffer[];
}
// Field Render Configuration
export interface FieldRenderConfig {
field: FieldDefinition;
value: any;
onChange: (value: any) => void;
error?: string;
disabled?: boolean;
visible?: boolean;
}
// Application Type List Response
export interface ApplicationTypeListResponse {
types: ApplicationType[];
total: number;
}
// PDF Template Upload Response
export interface PDFTemplateUploadResponse {
message: string;
filename: string;
fields: string[];
}
// API Error Response
export interface ApiError {
detail: string;
field?: string;
code?: string;
}
// API Response Wrapper
export type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: ApiError };
// User Role
export interface UserRole {
id: number;
name: string;
display_name: string;
description?: string;
is_admin: boolean;
can_review_budget: boolean;
can_review_finance: boolean;
can_vote: boolean;
permissions: string[];
}
// User
export interface User {
id: number;
email: string;
given_name?: string;
family_name?: string;
display_name: string;
picture_url?: string;
verification_status: string;
email_verified: boolean;
roles: UserRole[];
last_login_at?: string;
created_at: string;
updated_at: string;
}
// Session
export interface Session {
access_token: string;
refresh_token?: string;
expires_at: string;
user: User;
}
// Login Request
export interface LoginRequest {
email: string;
password?: string;
oidc_token?: string;
}
// Field Value Formatter
export type FieldValueFormatter = (value: any, field: FieldDefinition) => string;
// Field Value Validator
export type FieldValueValidator = (value: any, field: FieldDefinition) => string | undefined;
// Application State
export interface ApplicationState {
currentApplication?: DynamicApplication;
applicationTypes: ApplicationType[];
selectedType?: ApplicationType;
formData: DynamicFormData;
validation: Record<string, string>;
isDirty: boolean;
isSubmitting: boolean;
accessKey?: string;
}
// Notification
export interface Notification {
id: string;
type: "success" | "error" | "warning" | "info";
title: string;
message?: string;
duration?: number;
timestamp: Date;
}
// Field Component Props
export interface FieldComponentProps {
field: FieldDefinition;
value: any;
onChange: (value: any) => void;
error?: string;
disabled?: boolean;
formData?: Record<string, any>;
}
// Status Badge Props
export interface StatusBadgeProps {
status: StatusDefinition;
size?: "small" | "medium" | "large";
}
// Application Card Props
export interface ApplicationCardProps {
application: ApplicationListItem;
onClick?: (id: string) => void;
onStatusChange?: (id: string, newStatus: string) => void;
}
// Field Group
export interface FieldGroup {
section: string;
title?: string;
description?: string;
fields: FieldDefinition[];
collapsed?: boolean;
}
// Export Configuration
export interface ExportConfig {
format: "pdf" | "json" | "csv";
include_attachments?: boolean;
include_history?: boolean;
watermark?: string;
}
// Import Configuration
export interface ImportConfig {
format: "json" | "csv";
mapping?: Record<string, string>;
validate?: boolean;
dry_run?: boolean;
}
// Batch Operation
export interface BatchOperation {
operation: "export" | "status_change" | "delete";
application_ids: string[];
params?: Record<string, any>;
}
// Dashboard Statistics
export interface DashboardStats {
total_applications: number;
pending_applications: number;
approved_applications: number;
rejected_applications: number;
total_amount_requested: number;
total_amount_approved: number;
applications_by_type: Record<string, number>;
applications_by_status: Record<string, number>;
recent_applications: ApplicationListItem[];
}
// Activity Log
export interface ActivityLogEntry {
id: number;
user_id?: number;
user_name?: string;
action: string;
entity_type: string;
entity_id: string;
changes?: Record<string, any>;
ip_address?: string;
user_agent?: string;
timestamp: string;
}
// Help Text
export interface HelpText {
field_id: string;
title: string;
content: string;
examples?: string[];
links?: { label: string; url: string }[];
}
// Application Template
export interface ApplicationTemplate {
id: number;
name: string;
description?: string;
type_id: string;
field_defaults: Record<string, any>;
is_public: boolean;
created_by?: number;
created_at: string;
updated_at: string;
}