Compare commits

...

2 Commits

Author SHA1 Message Date
b41aa20948 state: Partial Backend Rebuild 2025-09-17 02:09:18 +02:00
ad697e5f54 feat: Complete redesign with OIDC auth, PDF upload, and enhanced workflow
BREAKING CHANGE: Major architecture overhaul removing LaTeX compilation

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

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

Migration Required:
- Database schema updates via Alembic
- Configuration of OIDC provider
- Upload of PDF templates to replace LaTeX
- Role mapping configuration
2025-09-17 00:42:57 +02:00
52 changed files with 17048 additions and 298 deletions

View File

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

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

310
README_NEW_ARCHITECTURE.md Normal file
View File

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

420
backend/ARCHITECTURE.md Normal file
View File

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

View File

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

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

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

View File

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

View File

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

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"
]

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

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

View File

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

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

View File

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

View File

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

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

@ -0,0 +1,208 @@
"""
Main FastAPI Application
This module creates and configures the main FastAPI application with
modular routers, middleware, and dependency injection.
"""
from contextlib import asynccontextmanager
from typing import Dict, Any
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import JSONResponse
import logging
from .config.settings import get_settings
from .core.container import create_container, get_container
from .core.database import init_database
from .api.middleware.rate_limit import RateLimitMiddleware
from .api.middleware.logging import LoggingMiddleware
from .api.middleware.error_handler import ErrorHandlerMiddleware
from .api.routes import (
attachment_router,
pdf_router,
auth_router,
health_router
)
from .api.application_types import router as application_types_router
from .api.dynamic_applications import router as dynamic_applications_router
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Application lifespan manager.
Handles startup and shutdown events.
"""
# Startup
logger.info("Starting up application...")
# Initialize settings
settings = get_settings()
# Initialize database
init_database(settings)
# Initialize dependency injection container
container = create_container(settings)
# PDF service will be initialized if needed
# Dynamic system doesn't require pre-registered providers
# Store container in app state
app.state.container = container
app.state.settings = settings
logger.info(f"Application started in {settings.app.environment} mode")
yield
# Shutdown
logger.info("Shutting down application...")
container.clear()
logger.info("Application shutdown complete")
def create_app() -> FastAPI:
"""
Create and configure the FastAPI application.
Returns:
Configured FastAPI application instance
"""
settings = get_settings()
# Create FastAPI app
app = FastAPI(
title=settings.app.app_name,
version=settings.app.app_version,
description="PDF Processing API for STUPA Applications",
docs_url=settings.app.docs_url,
redoc_url=settings.app.redoc_url,
openapi_url=settings.app.openapi_url,
lifespan=lifespan
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.security.cors_origins,
allow_credentials=settings.security.cors_credentials,
allow_methods=settings.security.cors_methods,
allow_headers=settings.security.cors_headers,
)
# Add trusted host middleware
if settings.app.environment == "production":
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["*"] # Configure based on your needs
)
# Add custom middleware
app.add_middleware(ErrorHandlerMiddleware)
app.add_middleware(LoggingMiddleware)
if settings.rate_limit.enabled:
app.add_middleware(
RateLimitMiddleware,
settings=settings.rate_limit
)
# Include routers
# Note: nginx strips /api/ prefix when proxying, so we don't add it here
app.include_router(
health_router,
prefix="/health",
tags=["health"]
)
app.include_router(
auth_router,
prefix="/auth",
tags=["authentication"]
)
app.include_router(
attachment_router,
prefix="/attachments",
tags=["attachments"]
)
app.include_router(
pdf_router,
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]:
"""Root endpoint returning API information"""
return {
"name": settings.app.app_name,
"version": settings.app.app_version,
"environment": settings.app.environment,
"docs": settings.app.docs_url,
"health": "/health"
}
# Global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Handle uncaught exceptions"""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
if settings.app.debug:
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"detail": str(exc),
"type": type(exc).__name__
}
)
else:
return JSONResponse(
status_code=500,
content={"error": "Internal server error"}
)
return app
# Create app instance
app = create_app()
# For development server
if __name__ == "__main__":
import uvicorn
settings = get_settings()
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=settings.app.environment == "development",
log_level=settings.app.log_level.lower()
)

View File

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

View File

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

@ -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'),
)

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
)

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

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

View File

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

View File

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

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

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;
}