stupa-pdf-api/backend/scripts/migrate_to_dynamic.py

501 lines
21 KiB
Python

#!/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()