Initial commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Flask Configuration
|
||||
SECRET_KEY=your-secret-key-here
|
||||
|
||||
# Email Configuration (for sending resume links)
|
||||
MAIL_SERVER=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USE_TLS=True
|
||||
MAIL_USERNAME=your-email@example.com
|
||||
MAIL_PASSWORD=your-app-password-here
|
||||
MAIL_DEFAULT_SENDER=noreply@example.com
|
||||
|
||||
# Company Settings
|
||||
COMPANY_NAME=Ihr Firmenname
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
applications/
|
||||
@@ -0,0 +1,274 @@
|
||||
# Job Application System
|
||||
|
||||
A simple, accessible Flask web application for job applications with email-based resume functionality and file uploads.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-step application process**: 5-page workflow from email capture to confirmation
|
||||
- **Email-based resume**: Applicants receive a unique link to resume their application
|
||||
- **Stateful sessions**: Applications can be paused and resumed at any time
|
||||
- **File uploads**: Support for CV, cover letter, and other documents (up to 3 files, 4 MB each)
|
||||
- **German interface**: All user-facing content in German
|
||||
- **File-based storage**: No database required - uses YAML for data serialization
|
||||
- **Accessible design**: Minimal CSS, no JavaScript required, keyboard-friendly
|
||||
- **Validation**: Comprehensive server-side validation for all inputs
|
||||
|
||||
## Application Workflow
|
||||
|
||||
1. **Email Capture**: User enters email and receives a resume link
|
||||
2. **Personal Information**: Name, address, phone, birth year, etc.
|
||||
3. **Motivation Questions**: Current job situation, motivation, qualifications, salary expectations
|
||||
4. **Document Upload**: Upload up to 3 documents (CV, cover letter, etc.)
|
||||
5. **Confirmation**: Success message with next steps
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- Python 3
|
||||
- Flask 3.0.0
|
||||
- PyYAML for data serialization
|
||||
- Flask-Mail for email sending
|
||||
- Minimal CSS for styling
|
||||
- No JavaScript required
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.8 or higher
|
||||
- pip (Python package manager)
|
||||
|
||||
### Setup Steps
|
||||
|
||||
1. **Clone or download this repository**
|
||||
|
||||
2. **Create a virtual environment** (recommended):
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **Install dependencies**:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **Configure environment variables**:
|
||||
- Copy `.env.example` to `.env`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
- Edit `.env` and configure your settings:
|
||||
```
|
||||
SECRET_KEY=your-secret-key-here
|
||||
MAIL_SERVER=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USE_TLS=True
|
||||
MAIL_USERNAME=your-email@example.com
|
||||
MAIL_PASSWORD=your-app-password
|
||||
MAIL_DEFAULT_SENDER=noreply@example.com
|
||||
COMPANY_NAME=Your Company Name
|
||||
```
|
||||
|
||||
### Email Configuration
|
||||
|
||||
For **Gmail**, you need to:
|
||||
1. Enable 2-factor authentication on your Google account
|
||||
2. Generate an "App Password" at https://myaccount.google.com/apppasswords
|
||||
3. Use the app password (not your regular password) in `MAIL_PASSWORD`
|
||||
|
||||
For **other email providers**, adjust `MAIL_SERVER` and `MAIL_PORT` accordingly.
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
The application will run at `http://127.0.0.1:5000`
|
||||
|
||||
### Production Deployment
|
||||
|
||||
For production, use a WSGI server like Gunicorn:
|
||||
|
||||
1. **Install Gunicorn**:
|
||||
```bash
|
||||
pip install gunicorn
|
||||
```
|
||||
|
||||
2. **Run with Gunicorn**:
|
||||
```bash
|
||||
gunicorn -w 4 -b 0.0.0.0:8000 app:app
|
||||
```
|
||||
|
||||
3. **Use a reverse proxy** (e.g., Nginx) in front of Gunicorn
|
||||
|
||||
## Usage
|
||||
|
||||
### Linking from Job Postings
|
||||
|
||||
Link to the application with a job parameter:
|
||||
|
||||
```
|
||||
https://your-domain.com/apply?job=JuniorMarketingAssistant
|
||||
```
|
||||
|
||||
The `job` parameter will be displayed throughout the application and stored with the application data.
|
||||
|
||||
### Resume Functionality
|
||||
|
||||
When applicants enter their email, they receive a link like:
|
||||
|
||||
```
|
||||
https://your-domain.com/resume/{session-id}
|
||||
```
|
||||
|
||||
This link allows them to resume their application at any time.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── app.py # Main Flask application
|
||||
├── config.py # Configuration settings
|
||||
├── requirements.txt # Python dependencies
|
||||
├── .env.example # Example environment variables
|
||||
├── README.md # This file
|
||||
├── templates/ # HTML templates
|
||||
│ ├── base.html
|
||||
│ ├── page1_email.html
|
||||
│ ├── page2_personal.html
|
||||
│ ├── page3_motivation.html
|
||||
│ ├── page4_upload.html
|
||||
│ └── page5_confirmation.html
|
||||
├── static/ # Static files
|
||||
│ └── style.css
|
||||
└── applications/ # Application data (created automatically)
|
||||
└── {session-id}/
|
||||
├── data.yaml
|
||||
└── attachments/
|
||||
```
|
||||
|
||||
## Data Storage
|
||||
|
||||
Each application is stored in its own folder under `./applications/{session-id}/`:
|
||||
|
||||
- `data.yaml`: Application data (personal info, answers, metadata)
|
||||
- `attachments/`: Uploaded documents
|
||||
|
||||
### Example data.yaml structure:
|
||||
|
||||
```yaml
|
||||
session_id: "abc123-def456-..."
|
||||
email: "applicant@example.com"
|
||||
job_name: "Junior Marketing Assistant"
|
||||
current_page: 4
|
||||
created_at: "2025-01-15T10:30:00"
|
||||
updated_at: "2025-01-15T11:45:00"
|
||||
personal_info:
|
||||
name: "Müller"
|
||||
firstname: "Anna"
|
||||
address: "Hauptstrasse 123"
|
||||
zip_code: "8001"
|
||||
city: "Zürich"
|
||||
phone: "+41 79 123 45 67"
|
||||
birth_year: "1990"
|
||||
civil_status: "ledig"
|
||||
motivation_answers:
|
||||
current_job: "..."
|
||||
motivation: "..."
|
||||
qualifications: "..."
|
||||
salary: "..."
|
||||
uploaded_files:
|
||||
- original_name: "CV.pdf"
|
||||
stored_name: "20250115_103000_CV.pdf"
|
||||
uploaded_at: "2025-01-15T10:30:00"
|
||||
size: 1048576
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Email (Page 1)
|
||||
- Valid email format required
|
||||
|
||||
### Personal Information (Page 2)
|
||||
- **Name**: Required, max 255 characters
|
||||
- **Firstname**: Required, max 255 characters
|
||||
- **Address**: Required, max 255 characters
|
||||
- **ZIP Code**: Required, numeric, max 10 digits
|
||||
- **City**: Required, max 255 characters
|
||||
- **Phone**: Required, international format (e.g., +41 79 123 45 67)
|
||||
- **Birth Year**: Required, 4 digits, between 1940 and 2010
|
||||
- **Civil Status**: Optional, max 255 characters
|
||||
|
||||
### Motivation (Page 3)
|
||||
- All fields optional
|
||||
- Each textarea limited to 3000 characters (~1 A4 page)
|
||||
|
||||
### File Upload (Page 4)
|
||||
- Maximum 3 files
|
||||
- Maximum 4 MB per file
|
||||
- Allowed formats: PDF, DOC, DOCX, TXT, JPG, JPEG, PNG
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Session IDs use cryptographically secure UUIDs
|
||||
- File uploads are validated for size and type
|
||||
- Filenames are sanitized to prevent path traversal
|
||||
- All validation is server-side
|
||||
- Email configuration uses environment variables
|
||||
|
||||
## Customization
|
||||
|
||||
### Changing Text Limits
|
||||
|
||||
Edit `config.py`:
|
||||
|
||||
```python
|
||||
MAX_STRING_LENGTH = 255
|
||||
MAX_TEXT_AREA_LENGTH = 3000
|
||||
MAX_FILE_SIZE = 4 * 1024 * 1024 # 4 MB
|
||||
MAX_FILES = 3
|
||||
```
|
||||
|
||||
### Changing Allowed File Types
|
||||
|
||||
Edit `config.py`:
|
||||
|
||||
```python
|
||||
ALLOWED_EXTENSIONS = {'pdf', 'doc', 'docx', 'txt', 'jpg', 'jpeg', 'png'}
|
||||
```
|
||||
|
||||
### Translating to Another Language
|
||||
|
||||
Edit all templates in `./templates/` - they contain German text that can be translated.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Email not sending
|
||||
|
||||
- Check your email credentials in `.env`
|
||||
- For Gmail, ensure you're using an app password, not your regular password
|
||||
- Check that `MAIL_USE_TLS` is set correctly for your email provider
|
||||
- Check application logs for error messages
|
||||
|
||||
### File upload errors
|
||||
|
||||
- Ensure the `./applications/` folder is writable
|
||||
- Check file size limits in your web server configuration
|
||||
- Verify allowed file extensions in `config.py`
|
||||
|
||||
### Session not resuming
|
||||
|
||||
- Check that the `./applications/{session-id}/` folder exists
|
||||
- Verify that `data.yaml` is valid YAML format
|
||||
- Check application logs for errors
|
||||
|
||||
## License
|
||||
|
||||
This project is provided as-is for use in job application processes.
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions, please contact your system administrator.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,497 @@
|
||||
import os
|
||||
import uuid
|
||||
import yaml
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash, session
|
||||
from flask_mail import Mail, Message
|
||||
from werkzeug.utils import secure_filename
|
||||
from config import Config
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
mail = Mail(app)
|
||||
|
||||
# Ensure applications folder exists
|
||||
Path(app.config['APPLICATIONS_FOLDER']).mkdir(exist_ok=True)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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"""
|
||||
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"""
|
||||
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"""
|
||||
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"""
|
||||
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"""
|
||||
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)
|
||||
|
||||
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'])
|
||||
|
||||
|
||||
@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)
|
||||
@@ -0,0 +1,37 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Config:
|
||||
"""Application configuration"""
|
||||
|
||||
# Flask settings
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
|
||||
# Email settings
|
||||
MAIL_SERVER = os.getenv('MAIL_SERVER', 'smtp.gmail.com')
|
||||
MAIL_PORT = int(os.getenv('MAIL_PORT', 587))
|
||||
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'True') == 'True'
|
||||
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
|
||||
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
|
||||
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'noreply@example.com')
|
||||
|
||||
# Company name for emails
|
||||
COMPANY_NAME = os.getenv('COMPANY_NAME', 'Unser Unternehmen')
|
||||
|
||||
# Application settings
|
||||
APPLICATIONS_FOLDER = './applications'
|
||||
MAX_FILE_SIZE = 4 * 1024 * 1024 # 4 MB in bytes
|
||||
MAX_FILES = 3
|
||||
ALLOWED_EXTENSIONS = {'pdf', 'doc', 'docx', 'txt', 'jpg', 'jpeg', 'png'}
|
||||
|
||||
# Text field limits
|
||||
MAX_STRING_LENGTH = 255
|
||||
MAX_TEXT_AREA_LENGTH = 3000 # Approximately one A4 page
|
||||
MAX_ZIP_DIGITS = 10
|
||||
|
||||
# Birth year validation
|
||||
MIN_BIRTH_YEAR = 1940
|
||||
MAX_BIRTH_YEAR = 2010
|
||||
@@ -0,0 +1,217 @@
|
||||
<objective>
|
||||
Build a complete, stateful Flask web application for job applications with email-based resume functionality, multi-step form validation, and file uploads. This system will be linked from external job postings and must handle the entire application lifecycle from initial contact through final submission, with the ability to resume at any point.
|
||||
|
||||
The application serves as a standalone application form that external job postings link to, passing a job name parameter to track which position the applicant is applying for.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
This is a simple but robust job application system that prioritizes:
|
||||
- Accessibility and simplicity (minimal CSS, no JavaScript required)
|
||||
- Stateful workflow that can be resumed via email link
|
||||
- File-based persistence (YAML + file storage, no database)
|
||||
- German language interface for all user-facing content
|
||||
- Clean validation at each step
|
||||
|
||||
The application workflow consists of 5 distinct pages:
|
||||
1. Email capture + session creation
|
||||
2. Basic personal information
|
||||
3. Motivation and qualification questions
|
||||
4. Document uploads (CV, cover letter, etc.)
|
||||
5. Final submission
|
||||
|
||||
Tech stack: Python 3, Flask, YAML for data serialization, minimal CSS, no JavaScript
|
||||
</context>
|
||||
|
||||
<requirements>
|
||||
|
||||
<workflow>
|
||||
**Page 1: Email Capture & Session Initialization**
|
||||
- Accept URL parameter: `/apply?job=JuniorMarketingAssistant`
|
||||
- Prompt for email address
|
||||
- Generate unique session ID (UUID)
|
||||
- Store session data: email, job name, session ID, timestamp
|
||||
- Send email with resume link containing session ID
|
||||
- Redirect to Page 2 on submit
|
||||
- Resume link format: `/resume/{session_id}` redirects to appropriate page
|
||||
|
||||
**Page 2: Basic Personal Information**
|
||||
Required fields:
|
||||
- Name (string, max 255 chars)
|
||||
- Firstname (string, max 255 chars)
|
||||
- Addresse (string, max 255 chars)
|
||||
- ZIP Code (integer, max 10 digits)
|
||||
- City (string, max 255 chars)
|
||||
- Phone number (international format validation)
|
||||
- Year of birth (4-digit integer, e.g., 1982)
|
||||
|
||||
Optional fields:
|
||||
- Civil status (string, max 255 chars)
|
||||
|
||||
**Page 3: Motivation & Qualifications**
|
||||
Four textarea fields (all optional but length-limited):
|
||||
1. "Bitte beschreiben Sie Ihre aktuelle berufliche Situation."
|
||||
2. "Was motiviert Sie, für uns zu arbeiten?"
|
||||
3. "Bitte nennen Sie uns Ihre besonderen Qualifikationen für diese Stelle."
|
||||
4. "Was sind Ihre Gehaltsvorstellungen (brutto, basierend auf Vollzeitbeschäftigung)?"
|
||||
|
||||
Each textarea limited to ~3000 characters (approximately one A4 page)
|
||||
|
||||
**Page 4: Document Upload**
|
||||
- Allow up to 3 document uploads
|
||||
- Maximum 4 MB per document
|
||||
- Preferred format: PDF
|
||||
- Display uploaded documents with remove option
|
||||
- Store in `./applications/{session_id}/attachments/` folder
|
||||
- Submit button with clear explanation:
|
||||
- "Bewerbung absenden" button
|
||||
- Note that application will be submitted but can be modified while active
|
||||
|
||||
**Page 5: Confirmation**
|
||||
- Display success message
|
||||
- Confirm application details
|
||||
- Provide information about next steps
|
||||
</workflow>
|
||||
|
||||
<technical_specifications>
|
||||
**Data Storage**
|
||||
- Store application data in YAML files: `./applications/{session_id}/data.yaml`
|
||||
- Store attachments in: `./applications/{session_id}/attachments/`
|
||||
- Each application gets its own folder structure
|
||||
- YAML structure should include: session_id, job_name, email, timestamp, current_page, personal_info, motivation_answers, uploaded_files
|
||||
|
||||
**Session Management**
|
||||
- Generate cryptographically secure UUIDs for session IDs
|
||||
- Track current page/progress in session data
|
||||
- Allow resume at any point via `/resume/{session_id}`
|
||||
- Validate session exists before allowing access
|
||||
|
||||
**Email Functionality**
|
||||
- Send resume link immediately after email capture
|
||||
- Email subject: "Ihre Bewerbung bei [Company] - Link zum Fortsetzen"
|
||||
- Email body: German text with resume link
|
||||
- Use Flask-Mail or similar for email sending
|
||||
- Email configuration should be environment-based
|
||||
|
||||
**Validation Requirements**
|
||||
- Email: Valid email format
|
||||
- Phone: International format (e.g., +41 79 123 45 67)
|
||||
- ZIP Code: Numeric, max 10 digits
|
||||
- Year of birth: 4 digits, reasonable range (1940-2010)
|
||||
- Text areas: Max ~3000 characters each
|
||||
- File uploads: Max 4 MB, count ≤ 3
|
||||
- Display clear, German error messages for all validation failures
|
||||
|
||||
**German Language**
|
||||
All labels, messages, and instructions must be in German:
|
||||
- Form labels and placeholders
|
||||
- Error messages
|
||||
- Button text
|
||||
- Email content
|
||||
- Success/confirmation messages
|
||||
</technical_specifications>
|
||||
|
||||
</requirements>
|
||||
|
||||
<implementation>
|
||||
|
||||
**Project Structure**
|
||||
Create the following structure:
|
||||
```
|
||||
./
|
||||
├── app.py # Main Flask application
|
||||
├── templates/
|
||||
│ ├── page1_email.html
|
||||
│ ├── page2_personal.html
|
||||
│ ├── page3_motivation.html
|
||||
│ ├── page4_upload.html
|
||||
│ └── page5_confirmation.html
|
||||
├── static/
|
||||
│ └── style.css # Minimal CSS for basic styling
|
||||
├── applications/ # Created dynamically, stores all application data
|
||||
├── config.py # Configuration (email settings, etc.)
|
||||
└── requirements.txt # Python dependencies
|
||||
```
|
||||
|
||||
**Key Implementation Details**
|
||||
|
||||
1. **Session Flow**: After each successful form submission, update the YAML file with current progress and redirect to next page. The `current_page` field tracks progress for resume functionality.
|
||||
|
||||
2. **Resume Functionality**: The `/resume/{session_id}` route should:
|
||||
- Load session data from YAML
|
||||
- Check `current_page` value
|
||||
- Redirect to appropriate page with form pre-filled
|
||||
|
||||
3. **Form Pre-filling**: When resuming, populate form fields from existing YAML data so users can review/modify their answers.
|
||||
|
||||
4. **File Upload Security**:
|
||||
- Validate file extensions (prefer PDF but allow common formats)
|
||||
- Sanitize filenames to prevent path traversal
|
||||
- Store with secure filenames (e.g., `{timestamp}_{sanitized_original_name}`)
|
||||
|
||||
5. **Email Configuration**: Use environment variables for email credentials. Include a `.env.example` file showing required configuration.
|
||||
|
||||
6. **Error Handling**:
|
||||
- Graceful handling of missing sessions
|
||||
- Clear error messages in German for validation failures
|
||||
- File system error handling (disk space, permissions)
|
||||
|
||||
7. **CSS Approach**: Keep styling minimal and accessible:
|
||||
- Clear visual hierarchy
|
||||
- Good contrast ratios
|
||||
- Responsive without requiring JavaScript
|
||||
- Form field spacing and readability
|
||||
- Clear focus states for keyboard navigation
|
||||
|
||||
**What to Avoid and Why**:
|
||||
- No JavaScript: Keeps the application accessible and simple, works without client-side execution
|
||||
- No database: File-based storage is simpler for small-scale applications and easier to deploy
|
||||
- No client-side validation: Server-side only ensures security and works in all environments
|
||||
- Avoid complex frameworks: Flask alone is sufficient, reducing dependencies and complexity
|
||||
</implementation>
|
||||
|
||||
<output>
|
||||
Create the following files with relative paths:
|
||||
|
||||
1. `./app.py` - Main Flask application with all routes and logic
|
||||
2. `./config.py` - Configuration management (email settings, file limits, etc.)
|
||||
3. `./requirements.txt` - Python dependencies (Flask, PyYAML, Flask-Mail, python-dotenv)
|
||||
4. `./templates/page1_email.html` - Email capture page (German)
|
||||
5. `./templates/page2_personal.html` - Personal information form (German)
|
||||
6. `./templates/page3_motivation.html` - Motivation questions (German)
|
||||
7. `./templates/page4_upload.html` - File upload interface (German)
|
||||
8. `./templates/page5_confirmation.html` - Success confirmation (German)
|
||||
9. `./static/style.css` - Minimal, accessible CSS
|
||||
10. `./.env.example` - Example environment configuration
|
||||
11. `./README.md` - Setup and deployment instructions in English
|
||||
|
||||
The `./applications/` directory will be created automatically by the application when the first application is submitted.
|
||||
</output>
|
||||
|
||||
<verification>
|
||||
Before declaring complete, verify your implementation:
|
||||
|
||||
1. **Session Flow Test**: Simulate the complete workflow from email capture through final submission
|
||||
2. **Resume Test**: Verify that the resume link correctly redirects to the appropriate page with pre-filled data
|
||||
3. **Validation Test**: Test all validation rules (email format, phone format, file size, character limits)
|
||||
4. **File Upload Test**: Verify files are stored correctly and can be removed
|
||||
5. **German Language**: Confirm all user-facing text is in German
|
||||
6. **YAML Storage**: Verify YAML files are created correctly and data persists
|
||||
7. **Error Handling**: Test with invalid session IDs and edge cases
|
||||
|
||||
Run the Flask application locally and test each page transition to ensure the stateful workflow functions correctly.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All 5 pages implemented with proper routing
|
||||
- Session management with unique IDs working
|
||||
- Email sending with resume link functional
|
||||
- All validation rules implemented with German error messages
|
||||
- File uploads working with size and count limits
|
||||
- Data persists in YAML format correctly
|
||||
- Resume functionality allows picking up at any page
|
||||
- Form pre-filling works when resuming
|
||||
- All user-facing content is in German
|
||||
- Minimal CSS provides clean, accessible interface
|
||||
- No JavaScript required for any functionality
|
||||
- README includes clear setup instructions
|
||||
</success_criteria>
|
||||
@@ -0,0 +1,5 @@
|
||||
Flask==3.0.0
|
||||
PyYAML==6.0.1
|
||||
Flask-Mail==0.9.1
|
||||
python-dotenv==1.0.0
|
||||
Werkzeug==3.0.1
|
||||
@@ -0,0 +1,414 @@
|
||||
/* Reset and Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f4f4f4;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background-color: #fff;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
border-bottom: 3px solid #007bff;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
color: #007bff;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
/* Page Introduction */
|
||||
.page-intro {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.page-intro h2 {
|
||||
color: #333;
|
||||
font-size: 1.4em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.page-intro p {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Messages (Flash Messages) */
|
||||
.messages {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px 15px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.message-success {
|
||||
background-color: #d4edda;
|
||||
border-color: #28a745;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.message-error {
|
||||
background-color: #f8d7da;
|
||||
border-color: #dc3545;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.message-warning {
|
||||
background-color: #fff3cd;
|
||||
border-color: #ffc107;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.message-info {
|
||||
background-color: #d1ecf1;
|
||||
border-color: #17a2b8;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.application-form {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.form-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
color: #555;
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1em;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.required-note {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
outline: 2px solid #007bff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 15px 30px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
/* File Upload */
|
||||
.uploaded-files {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.uploaded-files h3 {
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
padding: 10px;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.upload-section h3 {
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.upload-form {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Submit Section */
|
||||
.submit-section {
|
||||
background-color: #fff3cd;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ffc107;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.submit-section h3 {
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.important-note {
|
||||
color: #856404;
|
||||
margin-bottom: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Confirmation Page */
|
||||
.confirmation-page {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #28a745;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.success-message h2 {
|
||||
color: #155724;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.success-message p {
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.confirmation-details {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.confirmation-details h3 {
|
||||
color: #333;
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.confirmation-details ul {
|
||||
margin-left: 20px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.confirmation-details li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.contact-info h3 {
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-message {
|
||||
background-color: #d1ecf1;
|
||||
border: 1px solid #17a2b8;
|
||||
color: #0c5460;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.page-intro h2 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Bewerbungsformular{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>{% block header %}Bewerbungsformular{% endblock %}</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="message message-{{ category }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 - Alle Rechte vorbehalten</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bewerbung - E-Mail Eingabe{% endblock %}
|
||||
|
||||
{% block header %}Bewerbung: {{ job_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-intro">
|
||||
<h2>Willkommen zum Bewerbungsformular</h2>
|
||||
<p>Vielen Dank für Ihr Interesse an der Position <strong>{{ job_name }}</strong>.</p>
|
||||
<p>Bitte geben Sie zunächst Ihre E-Mail-Adresse ein. Sie erhalten dann einen Link, mit dem Sie Ihre Bewerbung jederzeit fortsetzen können.</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('submit_email') }}" class="application-form">
|
||||
<input type="hidden" name="job_name" value="{{ job_name }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">E-Mail-Adresse *</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="ihre.email@beispiel.de"
|
||||
aria-required="true">
|
||||
<small>Sie erhalten einen Link zum Fortsetzen Ihrer Bewerbung.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Weiter</button>
|
||||
</div>
|
||||
|
||||
<p class="required-note">* Pflichtfelder</p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,133 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bewerbung - Persönliche Daten{% endblock %}
|
||||
|
||||
{% block header %}Bewerbung: {{ job_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-intro">
|
||||
<h2>Schritt 1 von 3: Persönliche Daten</h2>
|
||||
<p>Bitte geben Sie Ihre persönlichen Informationen ein. Alle Felder mit * sind Pflichtfelder.</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('submit_personal', session_id=session_id) }}" class="application-form">
|
||||
|
||||
<div class="form-section">
|
||||
<h3>Persönliche Informationen</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Nachname *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
value="{{ data.name or '' }}"
|
||||
aria-required="true">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="firstname">Vorname *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstname"
|
||||
name="firstname"
|
||||
required
|
||||
maxlength="255"
|
||||
value="{{ data.firstname or '' }}"
|
||||
aria-required="true">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="address">Adresse *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address"
|
||||
name="address"
|
||||
required
|
||||
maxlength="255"
|
||||
value="{{ data.address or '' }}"
|
||||
placeholder="Straße und Hausnummer"
|
||||
aria-required="true">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="zip_code">PLZ *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="zip_code"
|
||||
name="zip_code"
|
||||
required
|
||||
maxlength="10"
|
||||
value="{{ data.zip_code or '' }}"
|
||||
pattern="[0-9]{1,10}"
|
||||
aria-required="true">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="city">Stadt *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
required
|
||||
maxlength="255"
|
||||
value="{{ data.city or '' }}"
|
||||
aria-required="true">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="phone">Telefonnummer *</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
required
|
||||
value="{{ data.phone or '' }}"
|
||||
placeholder="+41 79 123 45 67"
|
||||
pattern="\+\d{1,3}[\s\d]{1,20}"
|
||||
aria-required="true">
|
||||
<small>Bitte im internationalen Format eingeben (z.B. +41 79 123 45 67)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>Zusätzliche Informationen</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="birth_year">Geburtsjahr *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="birth_year"
|
||||
name="birth_year"
|
||||
required
|
||||
maxlength="4"
|
||||
value="{{ data.birth_year or '' }}"
|
||||
placeholder="1990"
|
||||
pattern="[0-9]{4}"
|
||||
aria-required="true">
|
||||
<small>Bitte vierstellig eingeben (z.B. 1990)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="civil_status">Zivilstand</label>
|
||||
<input
|
||||
type="text"
|
||||
id="civil_status"
|
||||
name="civil_status"
|
||||
maxlength="255"
|
||||
value="{{ data.civil_status or '' }}"
|
||||
placeholder="Optional">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Weiter zu Schritt 2</button>
|
||||
</div>
|
||||
|
||||
<p class="required-note">* Pflichtfelder</p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bewerbung - Motivation{% endblock %}
|
||||
|
||||
{% block header %}Bewerbung: {{ job_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-intro">
|
||||
<h2>Schritt 2 von 3: Motivation und Qualifikationen</h2>
|
||||
<p>Bitte beantworten Sie die folgenden Fragen. Alle Angaben sind optional, helfen uns aber, Sie besser kennenzulernen.</p>
|
||||
<p><small>Maximal ca. 3000 Zeichen pro Antwort (etwa eine A4-Seite)</small></p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('submit_motivation', session_id=session_id) }}" class="application-form">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="current_job">Bitte beschreiben Sie Ihre aktuelle berufliche Situation.</label>
|
||||
<textarea
|
||||
id="current_job"
|
||||
name="current_job"
|
||||
rows="6"
|
||||
maxlength="3000"
|
||||
placeholder="Optional - Ihre aktuelle Tätigkeit, Position, Verantwortlichkeiten...">{{ data.current_job or '' }}</textarea>
|
||||
<small class="char-count">Noch <span id="current_job_count">3000</span> Zeichen verfügbar</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="motivation">Was motiviert Sie, für uns zu arbeiten?</label>
|
||||
<textarea
|
||||
id="motivation"
|
||||
name="motivation"
|
||||
rows="6"
|
||||
maxlength="3000"
|
||||
placeholder="Optional - Ihre Motivation, Interessen an unserem Unternehmen...">{{ data.motivation or '' }}</textarea>
|
||||
<small class="char-count">Noch <span id="motivation_count">3000</span> Zeichen verfügbar</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="qualifications">Bitte nennen Sie uns Ihre besonderen Qualifikationen für diese Stelle.</label>
|
||||
<textarea
|
||||
id="qualifications"
|
||||
name="qualifications"
|
||||
rows="6"
|
||||
maxlength="3000"
|
||||
placeholder="Optional - Ihre Fähigkeiten, Erfahrungen, Ausbildungen...">{{ data.qualifications or '' }}</textarea>
|
||||
<small class="char-count">Noch <span id="qualifications_count">3000</span> Zeichen verfügbar</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="salary">Was sind Ihre Gehaltsvorstellungen (brutto, basierend auf Vollzeitbeschäftigung)?</label>
|
||||
<textarea
|
||||
id="salary"
|
||||
name="salary"
|
||||
rows="4"
|
||||
maxlength="3000"
|
||||
placeholder="Optional - Ihre Gehaltsvorstellungen...">{{ data.salary or '' }}</textarea>
|
||||
<small class="char-count">Noch <span id="salary_count">3000</span> Zeichen verfügbar</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Weiter zu Schritt 3</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<noscript>
|
||||
<p class="info-message">Die Zeichenzähler funktionieren nur mit aktiviertem JavaScript. Die Validierung erfolgt beim Absenden des Formulars.</p>
|
||||
</noscript>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bewerbung - Dokumente{% endblock %}
|
||||
|
||||
{% block header %}Bewerbung: {{ job_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-intro">
|
||||
<h2>Schritt 3 von 3: Dokumente hochladen</h2>
|
||||
<p>Laden Sie Ihre Bewerbungsunterlagen hoch (Lebenslauf, Anschreiben, Zeugnisse, etc.).</p>
|
||||
<p><small>
|
||||
Sie können bis zu {{ max_files }} Dokumente hochladen.<br>
|
||||
Maximale Dateigröße: 4 MB pro Dokument<br>
|
||||
Bevorzugtes Format: PDF
|
||||
</small></p>
|
||||
</div>
|
||||
|
||||
{% if uploaded_files %}
|
||||
<div class="uploaded-files">
|
||||
<h3>Hochgeladene Dokumente ({{ uploaded_files|length }}/{{ max_files }})</h3>
|
||||
<ul class="file-list">
|
||||
{% for file in uploaded_files %}
|
||||
<li class="file-item">
|
||||
<span class="file-name">{{ file.original_name }}</span>
|
||||
<span class="file-size">({{ "%.2f"|format(file.size / 1024 / 1024) }} MB)</span>
|
||||
<form method="POST" action="{{ url_for('remove_file', session_id=session_id, file_index=loop.index0) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger btn-small">Entfernen</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if uploaded_files|length < max_files %}
|
||||
<div class="upload-section">
|
||||
<h3>Dokument hochladen</h3>
|
||||
<form method="POST" action="{{ url_for('upload_file', session_id=session_id) }}" enctype="multipart/form-data" class="upload-form">
|
||||
<div class="form-group">
|
||||
<label for="file">Datei auswählen</label>
|
||||
<input
|
||||
type="file"
|
||||
id="file"
|
||||
name="file"
|
||||
accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png"
|
||||
required>
|
||||
<small>Erlaubte Formate: PDF, DOC, DOCX, TXT, JPG, JPEG, PNG</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-secondary">Datei hochladen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="info-message">Sie haben die maximale Anzahl von Dokumenten hochgeladen.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="submit-section">
|
||||
<h3>Bewerbung absenden</h3>
|
||||
<p class="important-note">
|
||||
Durch Klicken auf "Bewerbung absenden" wird Ihre Bewerbung eingereicht.
|
||||
Sie können Ihre Bewerbung später noch bearbeiten, solange sie aktiv ist.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('submit_application', session_id=session_id) }}">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary btn-large">Bewerbung absenden</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bewerbung - Bestätigung{% endblock %}
|
||||
|
||||
{% block header %}Bewerbung erfolgreich eingereicht{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="confirmation-page">
|
||||
<div class="success-message">
|
||||
<h2>Vielen Dank für Ihre Bewerbung!</h2>
|
||||
<p>Ihre Bewerbung für die Position <strong>{{ job_name }}</strong> wurde erfolgreich eingereicht.</p>
|
||||
</div>
|
||||
|
||||
<div class="confirmation-details">
|
||||
<h3>Was passiert als Nächstes?</h3>
|
||||
<ul>
|
||||
<li>Wir haben Ihre Bewerbung erhalten und werden sie sorgfältig prüfen.</li>
|
||||
<li>Eine Bestätigung wurde an <strong>{{ email }}</strong> gesendet.</li>
|
||||
<li>Wir werden uns in Kürze bei Ihnen melden.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Ihre Bewerbung bearbeiten</h3>
|
||||
<p>
|
||||
Sie haben den Link zum Fortsetzen Ihrer Bewerbung per E-Mail erhalten.
|
||||
Mit diesem Link können Sie Ihre Bewerbung jederzeit einsehen und bearbeiten, solange sie aktiv ist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="contact-info">
|
||||
<h3>Fragen?</h3>
|
||||
<p>Bei Fragen zu Ihrer Bewerbung können Sie sich gerne an uns wenden.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Zur Startseite</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user