Files
application-form-7/app.py
T

665 lines
22 KiB
Python
Raw Normal View History

2025-12-27 16:34:31 +01:00
import os
import uuid
import yaml
import re
2025-12-27 17:50:30 +01:00
from datetime import datetime, timedelta
2025-12-27 16:34:31 +01:00
from pathlib import Path
2025-12-27 21:57:10 +01:00
from flask import Flask, render_template, request, redirect, url_for, flash, session, send_from_directory, abort
2025-12-27 16:34:31 +01:00
from flask_mail import Mail, Message
2025-12-27 17:50:30 +01:00
from flask_wtf.csrf import CSRFProtect, CSRFError
2025-12-27 16:34:31 +01:00
from werkzeug.utils import secure_filename
from config import Config
app = Flask(__name__)
app.config.from_object(Config)
mail = Mail(app)
2025-12-27 17:50:30 +01:00
csrf = CSRFProtect(app)
2025-12-27 16:34:31 +01:00
# Ensure applications folder exists
Path(app.config['APPLICATIONS_FOLDER']).mkdir(exist_ok=True)
2025-12-27 17:50:30 +01:00
# Rate limiting helper
def check_rate_limit():
"""
Check if user is submitting forms too quickly.
Returns (allowed: bool, wait_seconds: int)
"""
now = datetime.now()
last_submit_str = session.get('last_submission_time')
if last_submit_str:
try:
last_submit = datetime.fromisoformat(last_submit_str)
elapsed = (now - last_submit).total_seconds()
if elapsed < app.config['RATE_LIMIT_SECONDS']:
wait_seconds = int(app.config['RATE_LIMIT_SECONDS'] - elapsed) + 1
return False, wait_seconds
except (ValueError, TypeError):
# Invalid timestamp, allow submission
pass
# Update last submission time
session['last_submission_time'] = now.isoformat()
return True, 0
# Error handlers
@app.errorhandler(CSRFError)
def handle_csrf_error(e):
"""Handle CSRF validation errors."""
flash('Sicherheitsfehler: Die Sitzung ist abgelaufen. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', 'error')
return redirect(url_for('page1_email')), 400
@app.errorhandler(429)
def handle_rate_limit_error(e):
"""Handle rate limit errors."""
return str(e), 429
2025-12-27 16:34:31 +01:00
def get_application_path(session_id):
"""Get the path to an application folder"""
return os.path.join(app.config['APPLICATIONS_FOLDER'], session_id)
def get_data_file_path(session_id):
"""Get the path to the application data YAML file"""
return os.path.join(get_application_path(session_id), 'data.yaml')
def get_attachments_path(session_id):
"""Get the path to the attachments folder"""
return os.path.join(get_application_path(session_id), 'attachments')
def load_application_data(session_id):
"""Load application data from YAML file"""
data_file = get_data_file_path(session_id)
if os.path.exists(data_file):
with open(data_file, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
return None
def save_application_data(session_id, data):
"""Save application data to YAML file"""
app_path = get_application_path(session_id)
Path(app_path).mkdir(parents=True, exist_ok=True)
data_file = get_data_file_path(session_id)
with open(data_file, 'w', encoding='utf-8') as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
def send_resume_email(email, session_id, job_name):
"""Send email with resume link"""
resume_link = url_for('resume_application', session_id=session_id, _external=True)
subject = f"Ihre Bewerbung bei {app.config['COMPANY_NAME']} - Link zum Fortsetzen"
body = f"""Guten Tag,
vielen Dank für Ihr Interesse an der Position "{job_name}" bei {app.config['COMPANY_NAME']}.
Sie können Ihre Bewerbung jederzeit über den folgenden Link fortsetzen:
{resume_link}
Dieser Link bleibt gültig und Sie können Ihre Bewerbung jederzeit bearbeiten.
Mit freundlichen Grüßen
{app.config['COMPANY_NAME']}
"""
try:
msg = Message(subject=subject, recipients=[email], body=body)
mail.send(msg)
return True
except Exception as e:
app.logger.error(f"Failed to send email: {e}")
return False
2025-12-27 21:57:10 +01:00
def send_hr_notification(session_id, app_data):
"""Send HR notification email when application is submitted"""
if not app.config.get('HR_EMAIL'):
app.logger.warning("HR_EMAIL not configured, skipping HR notification")
return False
# Build application URL
application_url = f"{app.config['APPLICATION_URL_BASE']}/application/{session_id}/"
# Get personal info
personal_info = app_data.get('personal_info', {})
firstname = personal_info.get('firstname', 'N/A')
name = personal_info.get('name', 'N/A')
email = app_data.get('email', 'N/A')
job_name = app_data.get('job_name', 'N/A')
file_count = len(app_data.get('uploaded_files', []))
# Format timestamp
submitted_at = app_data.get('submitted_at', datetime.now().isoformat())
try:
timestamp = datetime.fromisoformat(submitted_at).strftime('%d.%m.%Y %H:%M')
except (ValueError, TypeError):
timestamp = submitted_at
subject = f"Neue Bewerbung eingegangen: {job_name}"
body = f"""Sehr geehrtes HR-Team,
es ist eine neue Bewerbung eingegangen:
Position: {job_name}
Name: {firstname} {name}
E-Mail: {email}
Eingereicht am: {timestamp}
Anzahl der hochgeladenen Dokumente: {file_count}
Sie können die vollständige Bewerbung hier einsehen:
{application_url}
Mit freundlichen Grüßen,
Ihr Bewerbungssystem
"""
try:
msg = Message(subject=subject, recipients=[app.config['HR_EMAIL']], body=body)
mail.send(msg)
return True
except Exception as e:
app.logger.error(f"Failed to send HR notification email: {e}")
return False
2025-12-27 16:34:31 +01:00
def validate_email(email):
"""Validate email format"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def validate_phone(phone):
"""Validate international phone format"""
# Accepts formats like: +41 79 123 45 67, +41791234567, etc.
# Remove spaces and check if it matches the pattern
pattern = r'^\+\d{1,3}[\s\d]{1,20}$'
if not re.match(pattern, phone):
return False
# Ensure there are at least some digits after the country code
digits_only = re.sub(r'\s', '', phone)
return len(digits_only) >= 5 # At least +XX XXX format
def validate_year(year):
"""Validate birth year"""
try:
year_int = int(year)
return (app.config['MIN_BIRTH_YEAR'] <= year_int <= app.config['MAX_BIRTH_YEAR']
and len(str(year)) == 4)
except ValueError:
return False
def validate_zip(zip_code):
"""Validate ZIP code"""
try:
zip_int = int(zip_code)
return len(str(zip_code)) <= app.config['MAX_ZIP_DIGITS']
except ValueError:
return False
def allowed_file(filename):
"""Check if file extension is allowed"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
@app.route('/')
def index():
"""Redirect to apply page"""
return redirect(url_for('page1_email'))
@app.route('/apply')
def page1_email():
"""Page 1: Email capture and session initialization"""
job_name = request.args.get('job', 'Offene Position')
return render_template('page1_email.html', job_name=job_name)
@app.route('/apply/submit-email', methods=['POST'])
def submit_email():
"""Process email submission and create session"""
2025-12-27 17:50:30 +01:00
# Check rate limit
allowed, wait_seconds = check_rate_limit()
if not allowed:
flash(f'Bitte warten Sie noch {wait_seconds} Sekunden vor der nächsten Eingabe.', 'error')
return redirect(url_for('page1_email')), 429
2025-12-27 16:34:31 +01:00
email = request.form.get('email', '').strip()
job_name = request.form.get('job_name', 'Offene Position')
# Validate email
if not validate_email(email):
flash('Bitte geben Sie eine gültige E-Mail-Adresse ein.', 'error')
return redirect(url_for('page1_email', job=job_name))
# Generate session ID
session_id = str(uuid.uuid4())
# Create application data
app_data = {
'session_id': session_id,
'email': email,
'job_name': job_name,
'current_page': 2,
'created_at': datetime.now().isoformat(),
'updated_at': datetime.now().isoformat(),
'personal_info': {},
'motivation_answers': {},
'uploaded_files': []
}
# Save application data
save_application_data(session_id, app_data)
# Send resume email
if send_resume_email(email, session_id, job_name):
flash('Eine E-Mail mit einem Link zum Fortsetzen wurde an Ihre Adresse gesendet.', 'success')
else:
flash('Warnung: Die E-Mail konnte nicht gesendet werden. Bitte notieren Sie sich diese URL.', 'warning')
# Store session ID in Flask session
session['application_id'] = session_id
return redirect(url_for('page2_personal', session_id=session_id))
@app.route('/apply/<session_id>/personal')
def page2_personal(session_id):
"""Page 2: Basic personal information"""
app_data = load_application_data(session_id)
if not app_data:
flash('Bewerbung nicht gefunden.', 'error')
return redirect(url_for('page1_email'))
return render_template('page2_personal.html',
session_id=session_id,
job_name=app_data['job_name'],
data=app_data.get('personal_info', {}))
@app.route('/apply/<session_id>/submit-personal', methods=['POST'])
def submit_personal(session_id):
"""Process personal information submission"""
2025-12-27 17:50:30 +01:00
# Check rate limit
allowed, wait_seconds = check_rate_limit()
if not allowed:
flash(f'Bitte warten Sie noch {wait_seconds} Sekunden vor der nächsten Eingabe.', 'error')
return redirect(url_for('page2_personal', session_id=session_id)), 429
2025-12-27 16:34:31 +01:00
app_data = load_application_data(session_id)
if not app_data:
flash('Bewerbung nicht gefunden.', 'error')
return redirect(url_for('page1_email'))
# Get form data
name = request.form.get('name', '').strip()
firstname = request.form.get('firstname', '').strip()
address = request.form.get('address', '').strip()
zip_code = request.form.get('zip_code', '').strip()
city = request.form.get('city', '').strip()
phone = request.form.get('phone', '').strip()
birth_year = request.form.get('birth_year', '').strip()
civil_status = request.form.get('civil_status', '').strip()
# Validate required fields
errors = []
if not name or len(name) > app.config['MAX_STRING_LENGTH']:
errors.append('Name ist erforderlich und darf maximal 255 Zeichen lang sein.')
if not firstname or len(firstname) > app.config['MAX_STRING_LENGTH']:
errors.append('Vorname ist erforderlich und darf maximal 255 Zeichen lang sein.')
if not address or len(address) > app.config['MAX_STRING_LENGTH']:
errors.append('Adresse ist erforderlich und darf maximal 255 Zeichen lang sein.')
if not zip_code or not validate_zip(zip_code):
errors.append('PLZ ist erforderlich und darf maximal 10 Ziffern lang sein.')
if not city or len(city) > app.config['MAX_STRING_LENGTH']:
errors.append('Stadt ist erforderlich und darf maximal 255 Zeichen lang sein.')
if not phone or not validate_phone(phone):
errors.append('Telefonnummer ist erforderlich und muss im internationalen Format sein (z.B. +41 79 123 45 67).')
if not birth_year or not validate_year(birth_year):
errors.append(f'Geburtsjahr ist erforderlich und muss zwischen {app.config["MIN_BIRTH_YEAR"]} und {app.config["MAX_BIRTH_YEAR"]} liegen.')
if civil_status and len(civil_status) > app.config['MAX_STRING_LENGTH']:
errors.append('Zivilstand darf maximal 255 Zeichen lang sein.')
if errors:
for error in errors:
flash(error, 'error')
return redirect(url_for('page2_personal', session_id=session_id))
# Save personal information
app_data['personal_info'] = {
'name': name,
'firstname': firstname,
'address': address,
'zip_code': zip_code,
'city': city,
'phone': phone,
'birth_year': birth_year,
'civil_status': civil_status
}
app_data['current_page'] = 3
app_data['updated_at'] = datetime.now().isoformat()
save_application_data(session_id, app_data)
return redirect(url_for('page3_motivation', session_id=session_id))
@app.route('/apply/<session_id>/motivation')
def page3_motivation(session_id):
"""Page 3: Motivation and qualification questions"""
app_data = load_application_data(session_id)
if not app_data:
flash('Bewerbung nicht gefunden.', 'error')
return redirect(url_for('page1_email'))
return render_template('page3_motivation.html',
session_id=session_id,
job_name=app_data['job_name'],
data=app_data.get('motivation_answers', {}))
@app.route('/apply/<session_id>/submit-motivation', methods=['POST'])
def submit_motivation(session_id):
"""Process motivation questions submission"""
2025-12-27 17:50:30 +01:00
# Check rate limit
allowed, wait_seconds = check_rate_limit()
if not allowed:
flash(f'Bitte warten Sie noch {wait_seconds} Sekunden vor der nächsten Eingabe.', 'error')
return redirect(url_for('page3_motivation', session_id=session_id)), 429
2025-12-27 16:34:31 +01:00
app_data = load_application_data(session_id)
if not app_data:
flash('Bewerbung nicht gefunden.', 'error')
return redirect(url_for('page1_email'))
# Get form data
current_job = request.form.get('current_job', '').strip()
motivation = request.form.get('motivation', '').strip()
qualifications = request.form.get('qualifications', '').strip()
salary = request.form.get('salary', '').strip()
# Validate length
errors = []
if current_job and len(current_job) > app.config['MAX_TEXT_AREA_LENGTH']:
errors.append(f'Die Beschreibung Ihrer beruflichen Situation darf maximal {app.config["MAX_TEXT_AREA_LENGTH"]} Zeichen lang sein.')
if motivation and len(motivation) > app.config['MAX_TEXT_AREA_LENGTH']:
errors.append(f'Die Motivationsbeschreibung darf maximal {app.config["MAX_TEXT_AREA_LENGTH"]} Zeichen lang sein.')
if qualifications and len(qualifications) > app.config['MAX_TEXT_AREA_LENGTH']:
errors.append(f'Die Qualifikationsbeschreibung darf maximal {app.config["MAX_TEXT_AREA_LENGTH"]} Zeichen lang sein.')
if salary and len(salary) > app.config['MAX_TEXT_AREA_LENGTH']:
errors.append(f'Die Gehaltsvorstellungen dürfen maximal {app.config["MAX_TEXT_AREA_LENGTH"]} Zeichen lang sein.')
if errors:
for error in errors:
flash(error, 'error')
return redirect(url_for('page3_motivation', session_id=session_id))
# Save motivation answers
app_data['motivation_answers'] = {
'current_job': current_job,
'motivation': motivation,
'qualifications': qualifications,
'salary': salary
}
app_data['current_page'] = 4
app_data['updated_at'] = datetime.now().isoformat()
save_application_data(session_id, app_data)
return redirect(url_for('page4_upload', session_id=session_id))
@app.route('/apply/<session_id>/upload')
def page4_upload(session_id):
"""Page 4: Document upload"""
app_data = load_application_data(session_id)
if not app_data:
flash('Bewerbung nicht gefunden.', 'error')
return redirect(url_for('page1_email'))
return render_template('page4_upload.html',
session_id=session_id,
job_name=app_data['job_name'],
uploaded_files=app_data.get('uploaded_files', []),
max_files=app.config['MAX_FILES'])
@app.route('/apply/<session_id>/upload-file', methods=['POST'])
def upload_file(session_id):
"""Handle file upload"""
2025-12-27 17:50:30 +01:00
# Check rate limit
allowed, wait_seconds = check_rate_limit()
if not allowed:
flash(f'Bitte warten Sie noch {wait_seconds} Sekunden vor der nächsten Eingabe.', 'error')
return redirect(url_for('page4_upload', session_id=session_id)), 429
2025-12-27 16:34:31 +01:00
app_data = load_application_data(session_id)
if not app_data:
flash('Bewerbung nicht gefunden.', 'error')
return redirect(url_for('page1_email'))
# Check file count
if len(app_data.get('uploaded_files', [])) >= app.config['MAX_FILES']:
flash(f'Sie können maximal {app.config["MAX_FILES"]} Dokumente hochladen.', 'error')
return redirect(url_for('page4_upload', session_id=session_id))
# Check if file was uploaded
if 'file' not in request.files:
flash('Keine Datei ausgewählt.', 'error')
return redirect(url_for('page4_upload', session_id=session_id))
file = request.files['file']
if file.filename == '':
flash('Keine Datei ausgewählt.', 'error')
return redirect(url_for('page4_upload', session_id=session_id))
# Validate file
if not allowed_file(file.filename):
flash('Ungültiger Dateityp. Erlaubte Formate: PDF, DOC, DOCX, TXT, JPG, JPEG, PNG', 'error')
return redirect(url_for('page4_upload', session_id=session_id))
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > app.config['MAX_FILE_SIZE']:
flash(f'Die Datei ist zu groß. Maximale Größe: 4 MB', 'error')
return redirect(url_for('page4_upload', session_id=session_id))
# Save file
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
unique_filename = f"{timestamp}_{filename}"
attachments_path = get_attachments_path(session_id)
Path(attachments_path).mkdir(parents=True, exist_ok=True)
file_path = os.path.join(attachments_path, unique_filename)
file.save(file_path)
# Update application data
if 'uploaded_files' not in app_data:
app_data['uploaded_files'] = []
app_data['uploaded_files'].append({
'original_name': filename,
'stored_name': unique_filename,
'uploaded_at': datetime.now().isoformat(),
'size': file_size
})
app_data['updated_at'] = datetime.now().isoformat()
save_application_data(session_id, app_data)
flash('Datei erfolgreich hochgeladen.', 'success')
return redirect(url_for('page4_upload', session_id=session_id))
@app.route('/apply/<session_id>/remove-file/<int:file_index>', methods=['POST'])
def remove_file(session_id, file_index):
"""Remove an uploaded file"""
app_data = load_application_data(session_id)
if not app_data:
flash('Bewerbung nicht gefunden.', 'error')
return redirect(url_for('page1_email'))
uploaded_files = app_data.get('uploaded_files', [])
if 0 <= file_index < len(uploaded_files):
file_info = uploaded_files[file_index]
# Delete physical file
file_path = os.path.join(get_attachments_path(session_id), file_info['stored_name'])
if os.path.exists(file_path):
os.remove(file_path)
# Remove from data
uploaded_files.pop(file_index)
app_data['updated_at'] = datetime.now().isoformat()
save_application_data(session_id, app_data)
flash('Datei erfolgreich entfernt.', 'success')
else:
flash('Datei nicht gefunden.', 'error')
return redirect(url_for('page4_upload', session_id=session_id))
@app.route('/apply/<session_id>/submit-application', methods=['POST'])
def submit_application(session_id):
"""Submit final application"""
2025-12-27 17:50:30 +01:00
# Check rate limit
allowed, wait_seconds = check_rate_limit()
if not allowed:
flash(f'Bitte warten Sie noch {wait_seconds} Sekunden vor der nächsten Eingabe.', 'error')
return redirect(url_for('page4_upload', session_id=session_id)), 429
2025-12-27 16:34:31 +01:00
app_data = load_application_data(session_id)
if not app_data:
flash('Bewerbung nicht gefunden.', 'error')
return redirect(url_for('page1_email'))
# Mark as submitted
app_data['current_page'] = 5
app_data['submitted_at'] = datetime.now().isoformat()
app_data['updated_at'] = datetime.now().isoformat()
app_data['status'] = 'submitted'
save_application_data(session_id, app_data)
2025-12-27 21:57:10 +01:00
# Send HR notification
send_hr_notification(session_id, app_data)
2025-12-27 16:34:31 +01:00
return redirect(url_for('page5_confirmation', session_id=session_id))
@app.route('/apply/<session_id>/confirmation')
def page5_confirmation(session_id):
"""Page 5: Confirmation page"""
app_data = load_application_data(session_id)
if not app_data:
flash('Bewerbung nicht gefunden.', 'error')
return redirect(url_for('page1_email'))
return render_template('page5_confirmation.html',
session_id=session_id,
job_name=app_data['job_name'],
email=app_data['email'])
2025-12-27 21:57:10 +01:00
@app.route('/application/<session_id>/')
def view_application(session_id):
"""View complete application details (for HR)"""
app_data = load_application_data(session_id)
if not app_data:
abort(404)
return render_template('application_view.html',
session_id=session_id,
app_data=app_data)
@app.route('/application/<session_id>/download/<filename>')
def download_file(session_id, filename):
"""Download an uploaded file"""
app_data = load_application_data(session_id)
if not app_data:
abort(404)
# Verify the file is in the uploaded files list
uploaded_files = app_data.get('uploaded_files', [])
file_found = False
for file_info in uploaded_files:
if file_info.get('stored_name') == filename:
file_found = True
break
if not file_found:
abort(404)
# Serve the file
attachments_path = get_attachments_path(session_id)
file_path = os.path.join(attachments_path, filename)
if not os.path.exists(file_path):
abort(404)
return send_from_directory(attachments_path, filename, as_attachment=True)
2025-12-27 16:34:31 +01:00
@app.route('/resume/<session_id>')
def resume_application(session_id):
"""Resume application from email link"""
app_data = load_application_data(session_id)
if not app_data:
flash('Bewerbung nicht gefunden.', 'error')
return redirect(url_for('page1_email'))
# Store session ID in Flask session
session['application_id'] = session_id
# Redirect to current page
current_page = app_data.get('current_page', 2)
if current_page == 2:
return redirect(url_for('page2_personal', session_id=session_id))
elif current_page == 3:
return redirect(url_for('page3_motivation', session_id=session_id))
elif current_page == 4:
return redirect(url_for('page4_upload', session_id=session_id))
elif current_page == 5:
return redirect(url_for('page5_confirmation', session_id=session_id))
else:
return redirect(url_for('page2_personal', session_id=session_id))
if __name__ == '__main__':
app.run(debug=True)