Compare commits
2 Commits
main
...
feature/oi
| Author | SHA1 | Date | |
|---|---|---|---|
| b41aa20948 | |||
| ad697e5f54 |
169
.env.example
169
.env.example
@ -1,69 +1,128 @@
|
||||
# STUPA PDF API Configuration Example
|
||||
# Copy this file to .env and update with your values
|
||||
# ========================================
|
||||
# STUPA PDF API Configuration
|
||||
# ========================================
|
||||
|
||||
# Application Settings
|
||||
APP_NAME="STUPA PDF API"
|
||||
APP_VERSION="3.0.0"
|
||||
ENVIRONMENT="development"
|
||||
DEBUG=true
|
||||
LOG_LEVEL="INFO"
|
||||
TIMEZONE="Europe/Berlin"
|
||||
FRONTEND_URL="http://localhost:3001"
|
||||
|
||||
# API Settings
|
||||
API_PREFIX="/api"
|
||||
DOCS_URL="/docs"
|
||||
REDOC_URL="/redoc"
|
||||
OPENAPI_URL="/openapi.json"
|
||||
|
||||
# Feature Flags
|
||||
ENABLE_METRICS=false
|
||||
ENABLE_TRACING=false
|
||||
ENABLE_CACHE=true
|
||||
CACHE_TTL=300
|
||||
ENABLE_FORM_DESIGNER=true
|
||||
ENABLE_PDF_UPLOAD=true
|
||||
ENABLE_WORKFLOW=true
|
||||
|
||||
# ========================================
|
||||
# Database Configuration
|
||||
# ========================================
|
||||
MYSQL_HOST=db
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DB=stupa
|
||||
MYSQL_USER=stupa
|
||||
MYSQL_PASSWORD=your_secure_password_here
|
||||
MYSQL_ROOT_PASSWORD=your_secure_root_password_here
|
||||
MYSQL_PASSWORD=secret
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=20
|
||||
DB_POOL_PRE_PING=true
|
||||
DB_ECHO=false
|
||||
|
||||
# Authentication
|
||||
# Master key for admin access - keep this secure!
|
||||
MASTER_KEY=your_secure_master_key_here
|
||||
# ========================================
|
||||
# Security Settings
|
||||
# ========================================
|
||||
MASTER_KEY="change_me_to_secure_key"
|
||||
JWT_SECRET_KEY="change_me_to_secure_jwt_secret"
|
||||
JWT_ALGORITHM="HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
ENCRYPTION_KEY="change_me_to_32_byte_encryption_key"
|
||||
API_KEY_HEADER="X-API-Key"
|
||||
|
||||
# CORS Settings
|
||||
CORS_ORIGINS="http://localhost:3001,http://localhost:3000"
|
||||
CORS_CREDENTIALS=true
|
||||
CORS_METHODS="*"
|
||||
CORS_HEADERS="*"
|
||||
|
||||
# ========================================
|
||||
# OIDC/OAuth2 Settings (Nextcloud)
|
||||
# ========================================
|
||||
OIDC_ENABLED=true
|
||||
OIDC_ISSUER="https://nextcloud.example.com"
|
||||
OIDC_CLIENT_ID="your_client_id"
|
||||
OIDC_CLIENT_SECRET="your_client_secret"
|
||||
OIDC_REDIRECT_URI="http://localhost:3001/auth/callback"
|
||||
OIDC_SCOPE="openid profile email groups"
|
||||
OIDC_AUTO_CREATE_USERS=true
|
||||
|
||||
# OIDC Group Mappings (comma-separated)
|
||||
OIDC_ADMIN_GROUPS="admin,administrators"
|
||||
OIDC_BUDGET_REVIEWER_GROUPS="haushaltsbeauftragte,budget_reviewers"
|
||||
OIDC_FINANCE_REVIEWER_GROUPS="finanzreferent,finance_reviewers"
|
||||
OIDC_ASTA_GROUPS="asta,asta_members"
|
||||
|
||||
# ========================================
|
||||
# Email Settings
|
||||
# ========================================
|
||||
EMAIL_ENABLED=true
|
||||
SMTP_HOST="localhost"
|
||||
SMTP_PORT=587
|
||||
SMTP_TLS=true
|
||||
SMTP_SSL=false
|
||||
SMTP_USERNAME=""
|
||||
SMTP_PASSWORD=""
|
||||
EMAIL_FROM="noreply@example.com"
|
||||
EMAIL_FROM_NAME="STUPA System"
|
||||
|
||||
# Email Templates
|
||||
EMAIL_VERIFICATION_SUBJECT="Verifizieren Sie Ihre E-Mail-Adresse"
|
||||
EMAIL_MAGIC_LINK_SUBJECT="Anmelden bei STUPA"
|
||||
EMAIL_APP_NOTIFICATION_SUBJECT="Status-Update zu Ihrer Bewerbung"
|
||||
|
||||
# ========================================
|
||||
# Rate Limiting
|
||||
# Requests per minute per IP address
|
||||
# ========================================
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_IP_PER_MIN=60
|
||||
# Requests per minute per application key
|
||||
RATE_KEY_PER_MIN=30
|
||||
RATE_GLOBAL_PER_MIN=1000
|
||||
RATE_BURST_SIZE=10
|
||||
|
||||
# Application Settings
|
||||
# Timezone for the application
|
||||
TZ=Europe/Berlin
|
||||
# ========================================
|
||||
# Storage Settings
|
||||
# ========================================
|
||||
UPLOAD_DIR="/app/uploads"
|
||||
TEMPLATE_DIR="/app/templates"
|
||||
MAX_FILE_SIZE=10485760 # 10MB
|
||||
ALLOWED_EXTENSIONS="pdf,json,jpg,jpeg,png"
|
||||
TEMP_DIR="/tmp"
|
||||
ATTACHMENT_STORAGE="filesystem" # database or filesystem
|
||||
FILESYSTEM_PATH="/app/attachments"
|
||||
|
||||
# PDF Templates (paths inside container - don't change unless you know what you're doing)
|
||||
QSM_TEMPLATE=/app/assets/qsm.pdf
|
||||
VSM_TEMPLATE=/app/assets/vsm.pdf
|
||||
# ========================================
|
||||
# Workflow Settings
|
||||
# ========================================
|
||||
WORKFLOW_REQUIRED_VOTES=5
|
||||
WORKFLOW_APPROVAL_THRESHOLD=50.0 # Percentage
|
||||
WORKFLOW_REVIEW_TIMEOUT_DAYS=14
|
||||
WORKFLOW_VOTING_TIMEOUT_DAYS=7
|
||||
WORKFLOW_ALLOW_MANUAL_STATUS_CHANGE=true
|
||||
WORKFLOW_AUTO_LOCK_ON_SUBMISSION=true
|
||||
|
||||
# Frontend Configuration
|
||||
NODE_ENV=production
|
||||
|
||||
# Optional: CORS Configuration (for production)
|
||||
# CORS_ORIGINS=["https://your-domain.com"]
|
||||
|
||||
# Optional: Debug Mode (never enable in production)
|
||||
# DEBUG=false
|
||||
|
||||
# Optional: Database Connection Pool
|
||||
# DB_POOL_SIZE=10
|
||||
# DB_MAX_OVERFLOW=20
|
||||
|
||||
# Optional: File Upload Limits
|
||||
# MAX_UPLOAD_SIZE=104857600 # 100MB in bytes
|
||||
# MAX_ATTACHMENTS_PER_APP=30
|
||||
|
||||
# Optional: Session Configuration
|
||||
# SESSION_TIMEOUT=3600 # 1 hour in seconds
|
||||
|
||||
# Optional: Email Configuration (for future notifications)
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=notifications@example.com
|
||||
# SMTP_PASSWORD=smtp_password_here
|
||||
# SMTP_FROM=noreply@example.com
|
||||
|
||||
# Optional: Logging Configuration
|
||||
# LOG_LEVEL=INFO
|
||||
# LOG_FILE=/var/log/stupa-api.log
|
||||
|
||||
# Optional: Backup Configuration
|
||||
# BACKUP_ENABLED=true
|
||||
# BACKUP_SCHEDULE="0 2 * * *" # Daily at 2 AM
|
||||
# BACKUP_RETENTION_DAYS=30
|
||||
|
||||
# Production Security Headers (uncomment for production)
|
||||
# SECURE_HEADERS=true
|
||||
# HSTS_ENABLED=true
|
||||
# CSP_ENABLED=true
|
||||
# ========================================
|
||||
# Docker Compose Specific
|
||||
# ========================================
|
||||
MYSQL_ROOT_PASSWORD=rootsecret
|
||||
TZ="Europe/Berlin"
|
||||
|
||||
215
CHANGES.md
Normal file
215
CHANGES.md
Normal 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.
|
||||
374
DYNAMIC_SYSTEM_ARCHITECTURE.md
Normal file
374
DYNAMIC_SYSTEM_ARCHITECTURE.md
Normal 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
310
README_NEW_ARCHITECTURE.md
Normal 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
420
backend/ARCHITECTURE.md
Normal 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.
|
||||
@ -1,71 +1,67 @@
|
||||
# ---------- LaTeX Builder Stage ----------
|
||||
FROM texlive/texlive:latest AS latex-builder
|
||||
|
||||
# Install additional dependencies for LaTeX
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
inkscape \
|
||||
make \
|
||||
fonts-liberation \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& inkscape --version
|
||||
|
||||
WORKDIR /latex
|
||||
|
||||
# Copy the LaTeX source files from local worktrees
|
||||
COPY latex-qsm /latex/qsm
|
||||
COPY latex-vsm /latex/vsm
|
||||
|
||||
# Build QSM PDF
|
||||
WORKDIR /latex/qsm
|
||||
RUN latexmk -xelatex -interaction=nonstopmode -halt-on-error -shell-escape Main.tex && \
|
||||
cp Main.pdf /latex/qsm.pdf && \
|
||||
latexmk -c
|
||||
|
||||
# Build VSM PDF
|
||||
WORKDIR /latex/vsm
|
||||
RUN latexmk -xelatex -interaction=nonstopmode -shell-escape Main.tex && \
|
||||
cp Main.pdf /latex/vsm.pdf && \
|
||||
latexmk -c
|
||||
|
||||
# ---------- Base Python Stage ----------
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# System deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tzdata ca-certificates \
|
||||
qpdf \
|
||||
pdftk-java \
|
||||
libmupdf-dev \
|
||||
mupdf-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# ---------- Dependencies ----------
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libmariadb-dev \
|
||||
pkg-config \
|
||||
wget \
|
||||
curl \
|
||||
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 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
|
||||
COPY --from=latex-builder /latex/qsm.pdf /app/assets/qsm.pdf
|
||||
COPY --from=latex-builder /latex/vsm.pdf /app/assets/vsm.pdf
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/uploads \
|
||||
/app/templates \
|
||||
/app/attachments \
|
||||
/app/pdf_forms \
|
||||
/app/logs \
|
||||
/app/assets
|
||||
|
||||
# Set Python path
|
||||
ENV PYTHONPATH=/app/src
|
||||
# Set permissions
|
||||
RUN chmod -R 755 /app
|
||||
|
||||
# Configure PDF template paths
|
||||
ENV QSM_TEMPLATE=/app/assets/qsm.pdf \
|
||||
VSM_TEMPLATE=/app/assets/vsm.pdf
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# ---------- Run ----------
|
||||
CMD ["uvicorn", "service_api:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Run the application
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
|
||||
34
backend/docker-entrypoint.sh
Normal file
34
backend/docker-entrypoint.sh
Normal 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
|
||||
@ -1,20 +1,85 @@
|
||||
# Core API & HTTP
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.27
|
||||
# Core Framework
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Data parsing / validation
|
||||
pydantic>=2.6
|
||||
# Database
|
||||
sqlalchemy==2.0.25
|
||||
pymysql==1.1.0
|
||||
alembic==1.13.1
|
||||
cryptography==41.0.7
|
||||
|
||||
# PDF handling
|
||||
PyPDF2>=3.0.1
|
||||
PyMuPDF>=1.23.0
|
||||
# Authentication & Security
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.1.2
|
||||
authlib==1.3.0
|
||||
httpx==0.26.0
|
||||
requests==2.31.0
|
||||
|
||||
# DB (MySQL via SQLAlchemy + PyMySQL)
|
||||
SQLAlchemy>=2.0
|
||||
PyMySQL>=1.1
|
||||
# OIDC/OAuth2
|
||||
oauthlib==3.2.2
|
||||
requests-oauthlib==1.3.1
|
||||
|
||||
# Env handling
|
||||
python-dotenv>=1.0
|
||||
# PDF Processing
|
||||
pypdf==3.17.4
|
||||
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)
|
||||
python-multipart>=0.0.9
|
||||
# Email
|
||||
emails==0.6
|
||||
aiosmtplib==3.0.1
|
||||
email-validator==2.1.0.post1
|
||||
|
||||
# Caching & Sessions
|
||||
redis==5.0.1
|
||||
|
||||
# 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
|
||||
|
||||
@ -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()
|
||||
500
backend/scripts/migrate_to_dynamic.py
Normal file
500
backend/scripts/migrate_to_dynamic.py
Normal 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()
|
||||
611
backend/src/api/application_types.py
Normal file
611
backend/src/api/application_types.py
Normal 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"}
|
||||
831
backend/src/api/dynamic_applications.py
Normal file
831
backend/src/api/dynamic_applications.py
Normal 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)}")
|
||||
11
backend/src/api/middleware/__init__.py
Normal file
11
backend/src/api/middleware/__init__.py
Normal 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",
|
||||
]
|
||||
220
backend/src/api/middleware/error_handler.py
Normal file
220
backend/src/api/middleware/error_handler.py
Normal 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
|
||||
)
|
||||
206
backend/src/api/middleware/logging.py
Normal file
206
backend/src/api/middleware/logging.py
Normal 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
|
||||
165
backend/src/api/middleware/rate_limit.py
Normal file
165
backend/src/api/middleware/rate_limit.py
Normal 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
|
||||
80
backend/src/api/routes/__init__.py
Normal file
80
backend/src/api/routes/__init__.py
Normal 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
391
backend/src/api/v1/auth.py
Normal 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"
|
||||
}
|
||||
245
backend/src/config/database.py
Normal file
245
backend/src/config/database.py
Normal 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"
|
||||
]
|
||||
372
backend/src/config/settings.py
Normal file
372
backend/src/config/settings.py
Normal 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()
|
||||
397
backend/src/core/container.py
Normal file
397
backend/src/core/container.py
Normal 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
|
||||
429
backend/src/core/database.py
Normal file
429
backend/src/core/database.py
Normal 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
208
backend/src/main.py
Normal 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()
|
||||
)
|
||||
379
backend/src/migrations/001_add_oidc_and_templates.py
Normal file
379
backend/src/migrations/001_add_oidc_and_templates.py
Normal 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')
|
||||
""")
|
||||
248
backend/src/migrations/002_add_dynamic_application_system.py
Normal file
248
backend/src/migrations/002_add_dynamic_application_system.py
Normal 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')
|
||||
955
backend/src/models/application_type.py
Normal file
955
backend/src/models/application_type.py
Normal 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
257
backend/src/models/base.py
Normal 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
363
backend/src/models/user.py
Normal 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
|
||||
457
backend/src/repositories/application.py
Normal file
457
backend/src/repositories/application.py
Normal 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)}")
|
||||
466
backend/src/repositories/base.py
Normal file
466
backend/src/repositories/base.py
Normal 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)}")
|
||||
245
backend/src/repositories/role.py
Normal file
245
backend/src/repositories/role.py
Normal 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
|
||||
216
backend/src/repositories/user.py
Normal file
216
backend/src/repositories/user.py
Normal 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()
|
||||
563
backend/src/services/application.py
Normal file
563
backend/src/services/application.py
Normal 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
|
||||
380
backend/src/services/auth_email.py
Normal file
380
backend/src/services/auth_email.py
Normal 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
|
||||
454
backend/src/services/auth_oidc.py
Normal file
454
backend/src/services/auth_oidc.py
Normal 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()
|
||||
259
backend/src/services/auth_service.py
Normal file
259
backend/src/services/auth_service.py
Normal 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
|
||||
545
backend/src/services/base.py
Normal file
545
backend/src/services/base.py
Normal 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
|
||||
409
backend/src/services/notification_service.py
Normal file
409
backend/src/services/notification_service.py
Normal 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
522
backend/src/services/pdf.py
Normal 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)}
|
||||
392
backend/src/services/pdf_service.py
Normal file
392
backend/src/services/pdf_service.py
Normal 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
|
||||
)
|
||||
562
backend/src/services/pdf_template.py
Normal file
562
backend/src/services/pdf_template.py
Normal 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
260
backend/src/startup.py
Normal 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
139
backend/src/utils/crypto.py
Normal 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
359
backend/src/utils/email.py
Normal 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)
|
||||
348
backend/src/utils/file_storage.py
Normal file
348
backend/src/utils/file_storage.py
Normal 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()
|
||||
405
backend/src/utils/pdf_utils.py
Normal file
405
backend/src/utils/pdf_utils.py
Normal 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)}")
|
||||
463
backend/src/utils/validators.py
Normal file
463
backend/src/utils/validators.py
Normal 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
|
||||
@ -25,9 +25,28 @@ services:
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
ports:
|
||||
- "3306:3306"
|
||||
- "3307:3306"
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
networks:
|
||||
- stupa_network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: stupa_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- stupa_network
|
||||
|
||||
api:
|
||||
build:
|
||||
@ -39,36 +58,88 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# DB
|
||||
# Database
|
||||
MYSQL_HOST: db
|
||||
MYSQL_PORT: 3306
|
||||
MYSQL_DB: ${MYSQL_DB:-stupa}
|
||||
MYSQL_USER: ${MYSQL_USER:-stupa}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-secret}
|
||||
# Auth / Limits
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
|
||||
# Security
|
||||
MASTER_KEY: ${MASTER_KEY:-change_me}
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change_me_jwt}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change_me_encryption}
|
||||
|
||||
# OIDC Settings
|
||||
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/callback}
|
||||
OIDC_ADMIN_GROUPS: ${OIDC_ADMIN_GROUPS:-admin}
|
||||
OIDC_BUDGET_REVIEWER_GROUPS: ${OIDC_BUDGET_REVIEWER_GROUPS:-haushaltsbeauftragte}
|
||||
OIDC_FINANCE_REVIEWER_GROUPS: ${OIDC_FINANCE_REVIEWER_GROUPS:-finanzreferent}
|
||||
OIDC_ASTA_GROUPS: ${OIDC_ASTA_GROUPS:-asta}
|
||||
|
||||
# Email Settings
|
||||
EMAIL_ENABLED: ${EMAIL_ENABLED:-false}
|
||||
SMTP_HOST: ${SMTP_HOST:-localhost}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
|
||||
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-STUPA System}
|
||||
|
||||
# Rate Limiting
|
||||
RATE_IP_PER_MIN: ${RATE_IP_PER_MIN:-60}
|
||||
RATE_KEY_PER_MIN: ${RATE_KEY_PER_MIN:-30}
|
||||
# PDF-Templates (liegen im Image in /app/assets)
|
||||
QSM_TEMPLATE: /app/assets/qsm.pdf
|
||||
VSM_TEMPLATE: /app/assets/vsm.pdf
|
||||
# Optional: TZ
|
||||
|
||||
# Storage
|
||||
UPLOAD_DIR: /app/uploads
|
||||
TEMPLATE_DIR: /app/templates
|
||||
ATTACHMENT_STORAGE: ${ATTACHMENT_STORAGE:-filesystem}
|
||||
FILESYSTEM_PATH: /app/attachments
|
||||
|
||||
# Workflow
|
||||
WORKFLOW_REQUIRED_VOTES: ${WORKFLOW_REQUIRED_VOTES:-5}
|
||||
WORKFLOW_APPROVAL_THRESHOLD: ${WORKFLOW_APPROVAL_THRESHOLD:-50.0}
|
||||
|
||||
# Application
|
||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3001}
|
||||
ENVIRONMENT: ${ENVIRONMENT:-production}
|
||||
DEBUG: ${DEBUG:-false}
|
||||
TZ: ${TZ:-Europe/Berlin}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# Healthcheck: ping FastAPI root
|
||||
volumes:
|
||||
- ./backend/uploads:/app/uploads
|
||||
- ./backend/templates:/app/templates
|
||||
- ./backend/attachments:/app/attachments
|
||||
- pdf_forms:/app/pdf_forms
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/ || exit 1"]
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
networks:
|
||||
- stupa_network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
network: host
|
||||
args:
|
||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:8000}
|
||||
- VITE_OIDC_ENABLED=${OIDC_ENABLED:-false}
|
||||
- VITE_EMAIL_ENABLED=${EMAIL_ENABLED:-false}
|
||||
container_name: stupa_frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@ -82,6 +153,29 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
networks:
|
||||
- stupa_network
|
||||
|
||||
form_designer:
|
||||
image: node:18-alpine
|
||||
container_name: stupa_form_designer
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
command: npm run dev
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "3002:3000"
|
||||
volumes:
|
||||
- ./form-designer:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- VITE_API_URL=http://localhost:8000
|
||||
networks:
|
||||
- stupa_network
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
adminer:
|
||||
image: adminer:4
|
||||
@ -92,8 +186,32 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ADMINER_DEFAULT_SERVER: db
|
||||
ADMINER_DESIGN: pepa-linha-dark
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "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:
|
||||
db_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
pdf_forms:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
stupa_network:
|
||||
driver: bridge
|
||||
|
||||
478
frontend/src/api/dynamicClient.ts
Normal file
478
frontend/src/api/dynamicClient.ts
Normal 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;
|
||||
527
frontend/src/types/dynamic.ts
Normal file
527
frontend/src/types/dynamic.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user