From 64876a81f40c41017998aee778a59b885cc09951 Mon Sep 17 00:00:00 2001 From: Bernhard Kolb Date: Wed, 8 Apr 2026 21:23:55 +0200 Subject: [PATCH] Initial commit --- .gitignore | 39 +++++ Dockerfile | 12 ++ README.md | 39 +++++ app.py | 270 +++++++++++++++++++++++++++++++++ config.py | 19 +++ db.py | 54 +++++++ deploy_flask.sh | 72 +++++++++ docker-compose.example.yaml | 39 +++++ permissions.py | 38 +++++ requirements.txt | 6 + schema.sql | 103 +++++++++++++ static/css/style.css | 131 ++++++++++++++++ templates/admin/contacts.html | 13 ++ templates/admin/index.html | 12 ++ templates/admin/questions.html | 13 ++ templates/admin/topics.html | 13 ++ templates/base.html | 25 +++ templates/dashboard.html | 17 +++ templates/index.html | 12 ++ templates/login.html | 14 ++ templates/partials/footer.html | 5 + templates/partials/menu.html | 23 +++ templates/profile.html | 14 ++ templates/register.html | 16 ++ templates/result.html | 18 +++ templates/topic.html | 38 +++++ tools.py | 101 ++++++++++++ 27 files changed, 1156 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 config.py create mode 100644 db.py create mode 100644 deploy_flask.sh create mode 100644 docker-compose.example.yaml create mode 100644 permissions.py create mode 100644 requirements.txt create mode 100644 schema.sql create mode 100644 static/css/style.css create mode 100644 templates/admin/contacts.html create mode 100644 templates/admin/index.html create mode 100644 templates/admin/questions.html create mode 100644 templates/admin/topics.html create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/partials/footer.html create mode 100644 templates/partials/menu.html create mode 100644 templates/profile.html create mode 100644 templates/register.html create mode 100644 templates/result.html create mode 100644 templates/topic.html create mode 100644 tools.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6b4dc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +# Virtual environments +.venv/ +venv/ +env/ + +# IDE +.vscode/ +.idea/ + +# Logs +*.log + +# Flask / runtime +instance/ +*.sqlite +*.db + +# Uploads / generated files +files/ +images/generated/ +static/generated/ + +# Environment / secrets +.env +.env.* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..91ed9bc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--access-logfile", "/logs/gunicorn-access.log", "--error-logfile", "/logs/gunicorn-error.log", "app:app"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1e126e --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Projekt dasunternehmen + +Startgerüst für eine Flask-Anwendung mit: + +- Benutzerregistrierung mit Aktivierungslink per E-Mail +- Login / Logout +- Profilseite zum Passwortwechsel +- Themen mit 8 Ja/Nein-Fragen +- Speicherung eines Assessments +- Ergebnisgrafik mit Matplotlib +- Admin-Bereich als Grundstruktur für spätere CRUD-Seiten +- PostgreSQL-Anbindung + +## Start lokal + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +export FLASK_APP=app.py +flask run --host=0.0.0.0 --port=5000 +``` + +## Wichtiger Hinweis zum Admin-User + +In `schema.sql` wird der initiale Admin-User mit Passwort `topsecret` als Klartext in `passwort_hash` angelegt, genau wie von dir gewünscht. +In `app.py` ist dafür bereits eine kleine Sonderlogik enthalten: Beim **ersten Login** mit `topsecret` wird dieser Wert automatisch in einen echten Passwort-Hash umgewandelt. Danach läuft der Login normal über Hash-Prüfung. + +## Docker + +Eine Beispiel-Datei liegt in `docker-compose.example.yaml`. + +## Nächste sinnvolle Ausbaustufen + +- echte CRUD-Formulare im Admin-Bereich +- E-Mail-Template für Aktivierung und Ergebnisversand +- Validierung / CSRF-Schutz +- Pagination / Verlauf früherer Assessments +- bessere Rollenprüfung im Menü diff --git a/app.py b/app.py new file mode 100644 index 0000000..cb1fba0 --- /dev/null +++ b/app.py @@ -0,0 +1,270 @@ +from pathlib import Path +from flask import Flask, flash, redirect, render_template, request, session, url_for, send_from_directory +from werkzeug.security import check_password_hash, generate_password_hash +from config import Config +from db import execute, execute_returning, fetch_all, fetch_one +from permissions import admin_required, login_required +from tools import create_assessment_chart, generate_activation_token, send_mail, verify_activation_token + + +app = Flask(__name__) +app.config.from_object(Config) +chart_dir = Path('generated_charts') +chart_dir.mkdir(exist_ok=True) + + +@app.context_processor +def inject_user(): + user = None + is_admin = False + if session.get('user_id'): + user = fetch_one('SELECT id, name, email FROM benutzer WHERE id = %s', (session['user_id'],)) + admin_row = fetch_one( + ''' + SELECT 1 + FROM benutzer_gruppen bg + JOIN gruppen g ON g.id = bg.gruppen_id + WHERE bg.benutzer_id = %s AND g.gruppenname = 'Admins' + ''', + (session['user_id'],), + ) + is_admin = bool(admin_row) + return {'current_user': user, 'is_admin': is_admin} + + +@app.route('/') +def index(): + if session.get('user_id'): + return redirect(url_for('dashboard')) + return render_template('index.html') + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if request.method == 'POST': + name = request.form['name'].strip() + email = request.form['email'].strip().lower() + password = request.form['password'] + + existing = fetch_one('SELECT id FROM benutzer WHERE email = %s', (email,)) + if existing: + flash('E-Mail ist bereits registriert.', 'danger') + return render_template('register.html') + + password_hash = generate_password_hash(password) + execute( + ''' + INSERT INTO benutzer (name, email, passwort_hash, is_active) + VALUES (%s, %s, %s, FALSE) + ''', + (name, email, password_hash), + ) + + token = generate_activation_token(email) + activation_link = f"{Config.APP_BASE_URL}{url_for('activate_account', token=token)}" + send_mail( + email, + 'Account aktivieren', + f'Hallo {name},\n\nbitte aktiviere deinen Account:\n{activation_link}\n', + ) + + flash('Registrierung gespeichert. Bitte E-Mail zur Aktivierung prüfen.', 'success') + return redirect(url_for('login')) + return render_template('register.html') + + +@app.route('/activate/') +def activate_account(token): + try: + email = verify_activation_token(token) + except Exception: + flash('Aktivierungslink ist ungültig oder abgelaufen.', 'danger') + return redirect(url_for('login')) + + execute('UPDATE benutzer SET is_active = TRUE WHERE email = %s', (email,)) + flash('Account wurde aktiviert. Bitte anmelden.', 'success') + return redirect(url_for('login')) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + email = request.form['email'].strip().lower() + password = request.form['password'] + user = fetch_one('SELECT * FROM benutzer WHERE email = %s', (email,)) + + if not user: + flash('Ungültige Zugangsdaten.', 'danger') + return render_template('login.html') + if not user['is_active']: + flash('Account ist noch nicht aktiviert.', 'warning') + return render_template('login.html') + + stored_password = user['passwort_hash'] + password_ok = False + if stored_password == 'topsecret' and password == 'topsecret': + new_hash = generate_password_hash(password) + execute('UPDATE benutzer SET passwort_hash = %s WHERE id = %s', (new_hash, user['id'])) + password_ok = True + else: + password_ok = check_password_hash(stored_password, password) + + if not password_ok: + flash('Ungültige Zugangsdaten.', 'danger') + return render_template('login.html') + + session['user_id'] = user['id'] + execute('UPDATE benutzer SET last_login = NOW() WHERE id = %s', (user['id'],)) + execute('INSERT INTO accesslog (userid) VALUES (%s)', (user['id'],)) + return redirect(url_for('dashboard')) + return render_template('login.html') + + +@app.route('/logout') +def logout(): + session.clear() + flash('Erfolgreich abgemeldet.', 'success') + return redirect(url_for('login')) + + +@app.route('/dashboard') +@login_required +def dashboard(): + themen = fetch_all('SELECT * FROM thema ORDER BY id') + return render_template('dashboard.html', themen=themen) + + +@app.route('/profil', methods=['GET', 'POST']) +@login_required +def profile(): + user = fetch_one('SELECT id, name, email FROM benutzer WHERE id = %s', (session['user_id'],)) + if request.method == 'POST': + new_password = request.form['password'] + execute( + 'UPDATE benutzer SET passwort_hash = %s WHERE id = %s', + (generate_password_hash(new_password), session['user_id']) + ) + flash('Passwort wurde geändert.', 'success') + return redirect(url_for('profile')) + return render_template('profile.html', user=user) + + +@app.route('/thema/', methods=['GET', 'POST']) +@login_required +def topic(thema_id): + thema = fetch_one('SELECT * FROM thema WHERE id = %s', (thema_id,)) + fragen = fetch_all('SELECT * FROM fragen WHERE themaid = %s ORDER BY id', (thema_id,)) + ansprechpartner = fetch_all( + ''' + SELECT a.* + FROM ansprechpartner a + JOIN themaansprechpartner ta ON ta.ansprechpartnerid = a.id + WHERE ta.themaid = %s + ORDER BY a.name + ''', + (thema_id,), + ) + + if request.method == 'POST': + assessment_id = request.form.get('assessment_id') + if not assessment_id: + assessment = execute_returning( + 'INSERT INTO assessment (userid) VALUES (%s) RETURNING id', + (session['user_id'],), + ) + assessment_id = assessment['id'] + + for frage in fragen: + value = request.form.get(f'frage_{frage["id"]}') + if value not in ('ja', 'nein'): + flash('Bitte alle Fragen beantworten.', 'warning') + return render_template( + 'topic.html', + thema=thema, + fragen=fragen, + ansprechpartner=ansprechpartner, + assessment_id=assessment_id, + ) + execute( + ''' + INSERT INTO assessmentanswer (assessmentid, themaid, frageid, antwort) + VALUES (%s, %s, %s, %s) + ON CONFLICT (assessmentid, frageid) + DO UPDATE SET antwort = EXCLUDED.antwort + ''', + (assessment_id, thema_id, frage['id'], value == 'ja'), + ) + + next_topic = fetch_one('SELECT id FROM thema WHERE id > %s ORDER BY id LIMIT 1', (thema_id,)) + if next_topic: + return redirect(url_for('topic', thema_id=next_topic['id'], assessment_id=assessment_id)) + return redirect(url_for('assessment_result', assessment_id=assessment_id)) + + assessment_id = request.args.get('assessment_id', '') + return render_template( + 'topic.html', + thema=thema, + fragen=fragen, + ansprechpartner=ansprechpartner, + assessment_id=assessment_id, + ) + + +@app.route('/assessment//result') +@login_required +def assessment_result(assessment_id): + rows = fetch_all( + ''' + SELECT t.kurztitel, COUNT(*) FILTER (WHERE aa.antwort = TRUE) AS ja_anzahl + FROM thema t + LEFT JOIN assessmentanswer aa ON aa.themaid = t.id AND aa.assessmentid = %s + GROUP BY t.id, t.kurztitel + ORDER BY t.id + ''', + (assessment_id,), + ) + labels = [row['kurztitel'] for row in rows] + values = [int(row['ja_anzahl']) for row in rows] + filename = f'assessment_{assessment_id}.png' + output_path = chart_dir / filename + create_assessment_chart(labels, values, output_path) + return render_template('result.html', assessment_id=assessment_id, chart_file=filename, rows=rows) + + +@app.route('/generated_charts/') +@login_required +def generated_chart(filename): + return send_from_directory(chart_dir, filename) + + +@app.route('/admin') +@admin_required +def admin_index(): + return render_template('admin/index.html') + + +@app.route('/admin/themen') +@admin_required +def admin_topics(): + themen = fetch_all('SELECT * FROM thema ORDER BY id') + return render_template('admin/topics.html', themen=themen) + + +@app.route('/admin/fragen') +@admin_required +def admin_questions(): + fragen = fetch_all( + '''SELECT f.id, f.text, t.kurztitel FROM fragen f JOIN thema t ON t.id = f.themaid ORDER BY t.id, f.id''' + ) + return render_template('admin/questions.html', fragen=fragen) + + +@app.route('/admin/ansprechpartner') +@admin_required +def admin_contacts(): + contacts = fetch_all('SELECT * FROM ansprechpartner ORDER BY name') + return render_template('admin/contacts.html', contacts=contacts) + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/config.py b/config.py new file mode 100644 index 0000000..82d8462 --- /dev/null +++ b/config.py @@ -0,0 +1,19 @@ +import os + +class Config: + SECRET_KEY = os.getenv('SECRET_KEY', 'change-me-in-production') + + DB_HOST = os.getenv('DB_HOST', 'db') + DB_NAME = os.getenv('DB_NAME', 'UnternehmenDB') + DB_USER = os.getenv('DB_USER', 'UnternehmenUser') + DB_PASSWORD = os.getenv('DB_PASSWORD', 'UnternehmenPWD') + DB_PORT = int(os.getenv('DB_PORT', '5432')) + + SMTP_SERVER = os.getenv('SMTP_SERVER', 'smtp.example.com') + SMTP_PORT = int(os.getenv('SMTP_PORT', '587')) + SMTP_USERNAME = os.getenv('SMTP_USERNAME', '') + SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', '') + MAIL_SENDER = os.getenv('MAIL_SENDER', 'noreply@example.com') + MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'true').lower() == 'true' + + APP_BASE_URL = os.getenv('APP_BASE_URL', 'http://localhost:5050') diff --git a/db.py b/db.py new file mode 100644 index 0000000..51f654b --- /dev/null +++ b/db.py @@ -0,0 +1,54 @@ +from contextlib import contextmanager +import psycopg2 +import psycopg2.extras +from config import Config + + +def get_connection(): + return psycopg2.connect( + host=Config.DB_HOST, + dbname=Config.DB_NAME, + user=Config.DB_USER, + password=Config.DB_PASSWORD, + port=Config.DB_PORT, + cursor_factory=psycopg2.extras.RealDictCursor, + ) + + +@contextmanager +def get_cursor(commit=False): + conn = get_connection() + cur = conn.cursor() + try: + yield cur + if commit: + conn.commit() + except Exception: + conn.rollback() + raise + finally: + cur.close() + conn.close() + + +def fetch_one(query, params=None): + with get_cursor() as cur: + cur.execute(query, params or ()) + return cur.fetchone() + + +def fetch_all(query, params=None): + with get_cursor() as cur: + cur.execute(query, params or ()) + return cur.fetchall() + + +def execute(query, params=None): + with get_cursor(commit=True) as cur: + cur.execute(query, params or ()) + + +def execute_returning(query, params=None): + with get_cursor(commit=True) as cur: + cur.execute(query, params or ()) + return cur.fetchone() diff --git a/deploy_flask.sh b/deploy_flask.sh new file mode 100644 index 0000000..d3a8448 --- /dev/null +++ b/deploy_flask.sh @@ -0,0 +1,72 @@ +#!/bin/bash +set -euo pipefail + +SRC_ROOT="/Volumes/MacBook SD/Projekte/DasUnternehmen" +DST_ROOT="/Volumes/docker/flask-postgres/app-unternehmen" + +NAS_USER="BKolb" +NAS_HOST="192.168.0.10" +CONTAINER_NAME="flask_unternehmen" + +echo "Starte Deployment..." + +[ -d "$SRC_ROOT" ] || { echo "Quelle fehlt: $SRC_ROOT"; exit 1; } +[ -d "$DST_ROOT" ] || { echo "Ziel fehlt: $DST_ROOT"; exit 1; } + +echo "Synchronisiere Projektdateien ..." +rsync -av --delete \ + --exclude '.DS_Store' \ + --exclude '._*' \ + --exclude '__pycache__/' \ + --exclude '*.pyc' \ + --exclude '*.pyo' \ + --exclude '*.log' \ + --exclude '.git/' \ + --exclude '.venv/' \ + --exclude 'venv/' \ + --exclude 'node_modules/' \ + --exclude 'files/' \ + --exclude 'images/' \ + --exclude 'styles/' \ + "$SRC_ROOT/" "$DST_ROOT/" + +echo "Synchronisiere images/ ..." +if [ -d "$SRC_ROOT/images" ]; then + mkdir -p "$DST_ROOT/images" + rsync -av --delete \ + --exclude '.DS_Store' \ + --exclude '._*' \ + --exclude 'videos/' \ + "$SRC_ROOT/images/" "$DST_ROOT/images/" +else + echo "Hinweis: $SRC_ROOT/images nicht gefunden, übersprungen." +fi + +echo "Synchronisiere styles/ ..." +if [ -d "$SRC_ROOT/styles" ]; then + mkdir -p "$DST_ROOT/styles" + rsync -av --delete \ + --exclude '.DS_Store' \ + --exclude '._*' \ + "$SRC_ROOT/styles/" "$DST_ROOT/styles/" +else + echo "Hinweis: $SRC_ROOT/styles nicht gefunden, übersprungen." +fi + +echo "Synchronisiere templates/ ..." +if [ -d "$SRC_ROOT/templates" ]; then + mkdir -p "$DST_ROOT/templates" + rsync -av --delete \ + --exclude '.DS_Store' \ + --exclude '._*' \ + "$SRC_ROOT/templates/" "$DST_ROOT/templates/" +else + echo "Hinweis: $SRC_ROOT/templates nicht gefunden, übersprungen." +fi + +echo "files/ wird bewusst nicht angefasst." + +echo "Container manuell neu starten oder neu bauen falls requirements/Dockerfile geändert wurden." +# ssh "${NAS_USER}@${NAS_HOST}" "/usr/bin/docker restart ${CONTAINER_NAME}" + +echo "Deployment abgeschlossen." \ No newline at end of file diff --git a/docker-compose.example.yaml b/docker-compose.example.yaml new file mode 100644 index 0000000..0c3ca92 --- /dev/null +++ b/docker-compose.example.yaml @@ -0,0 +1,39 @@ +services: + web: + build: . + container_name: dasunternehmen_web + restart: unless-stopped + ports: + - "5050:5000" + environment: + SECRET_KEY: change-me + APP_BASE_URL: http://localhost:5050 + DB_HOST: db + DB_NAME: UnternehmenDB + DB_USER: UnternehmenUser + DB_PASSWORD: UnternehmenPWD + DB_PORT: 5432 + SMTP_SERVER: smtp.example.com + SMTP_PORT: 587 + SMTP_USERNAME: smtp-user + SMTP_PASSWORD: smtp-password + MAIL_SENDER: noreply@dasunternehmen.com + depends_on: + - db + + db: + image: postgres:16 + container_name: dasunternehmen_db + restart: unless-stopped + environment: + POSTGRES_DB: UnternehmenDB + POSTGRES_USER: UnternehmenUser + POSTGRES_PASSWORD: UnternehmenPWD + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + +volumes: + postgres_data: diff --git a/permissions.py b/permissions.py new file mode 100644 index 0000000..53750a2 --- /dev/null +++ b/permissions.py @@ -0,0 +1,38 @@ +from functools import wraps +from flask import session, redirect, url_for, flash +from db import fetch_one + + +def login_required(view_func): + @wraps(view_func) + def wrapper(*args, **kwargs): + if not session.get('user_id'): + flash('Bitte zuerst anmelden.', 'warning') + return redirect(url_for('login')) + return view_func(*args, **kwargs) + return wrapper + + + +def admin_required(view_func): + @wraps(view_func) + def wrapper(*args, **kwargs): + user_id = session.get('user_id') + if not user_id: + flash('Bitte zuerst anmelden.', 'warning') + return redirect(url_for('login')) + + row = fetch_one( + ''' + SELECT 1 + FROM benutzer_gruppen bg + JOIN gruppen g ON g.id = bg.gruppen_id + WHERE bg.benutzer_id = %s AND g.gruppenname = 'Admins' + ''', + (user_id,), + ) + if not row: + flash('Keine Berechtigung für diesen Bereich.', 'danger') + return redirect(url_for('dashboard')) + return view_func(*args, **kwargs) + return wrapper diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9d683bf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.1.0 +psycopg2-binary==2.9.10 +Werkzeug==3.1.3 +itsdangerous==2.2.0 +matplotlib==3.10.1 +gunicorn==23.0.0 \ No newline at end of file diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..49c3e65 --- /dev/null +++ b/schema.sql @@ -0,0 +1,103 @@ +DROP TABLE IF EXISTS assessmentanswer CASCADE; +DROP TABLE IF EXISTS assessment CASCADE; +DROP TABLE IF EXISTS themaansprechpartner CASCADE; +DROP TABLE IF EXISTS ansprechpartner CASCADE; +DROP TABLE IF EXISTS fragen CASCADE; +DROP TABLE IF EXISTS thema CASCADE; +DROP TABLE IF EXISTS accesslog CASCADE; +DROP TABLE IF EXISTS benutzer_gruppen CASCADE; +DROP TABLE IF EXISTS gruppen CASCADE; +DROP TABLE IF EXISTS benutzer CASCADE; + +CREATE TABLE benutzer ( + id SERIAL PRIMARY KEY, + name VARCHAR(150) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + passwort_hash VARCHAR(255) NOT NULL, + last_login TIMESTAMP NULL, + is_active BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE gruppen ( + id SERIAL PRIMARY KEY, + gruppenname VARCHAR(100) NOT NULL UNIQUE, + info TEXT +); + +CREATE TABLE benutzer_gruppen ( + benutzer_id INTEGER NOT NULL REFERENCES benutzer(id) ON DELETE CASCADE, + gruppen_id INTEGER NOT NULL REFERENCES gruppen(id) ON DELETE CASCADE, + PRIMARY KEY (benutzer_id, gruppen_id) +); + +CREATE TABLE accesslog ( + id SERIAL PRIMARY KEY, + datetime TIMESTAMP NOT NULL DEFAULT NOW(), + userid INTEGER NOT NULL REFERENCES benutzer(id) ON DELETE CASCADE +); + +CREATE TABLE thema ( + id SERIAL PRIMARY KEY, + kurztitel VARCHAR(50) NOT NULL, + titel VARCHAR(255) NOT NULL, + infotext TEXT, + zusatztext TEXT +); + +CREATE TABLE fragen ( + id SERIAL PRIMARY KEY, + themaid INTEGER NOT NULL REFERENCES thema(id) ON DELETE CASCADE, + text TEXT NOT NULL +); + +CREATE TABLE ansprechpartner ( + id SERIAL PRIMARY KEY, + name VARCHAR(150) NOT NULL, + email VARCHAR(255) NOT NULL, + infotext TEXT +); + +CREATE TABLE themaansprechpartner ( + themaid INTEGER NOT NULL REFERENCES thema(id) ON DELETE CASCADE, + ansprechpartnerid INTEGER NOT NULL REFERENCES ansprechpartner(id) ON DELETE CASCADE, + PRIMARY KEY (themaid, ansprechpartnerid) +); + +CREATE TABLE assessment ( + id SERIAL PRIMARY KEY, + datetime TIMESTAMP NOT NULL DEFAULT NOW(), + userid INTEGER NOT NULL REFERENCES benutzer(id) ON DELETE CASCADE +); + +CREATE TABLE assessmentanswer ( + assessmentid INTEGER NOT NULL REFERENCES assessment(id) ON DELETE CASCADE, + themaid INTEGER NOT NULL REFERENCES thema(id) ON DELETE CASCADE, + frageid INTEGER NOT NULL REFERENCES fragen(id) ON DELETE CASCADE, + antwort BOOLEAN NOT NULL, + PRIMARY KEY (assessmentid, frageid) +); + +INSERT INTO benutzer (name, email, passwort_hash, is_active) +VALUES ('AdminUser', 'admin@dasunternehmen.local', 'topsecret', TRUE); + +INSERT INTO gruppen (gruppenname, info) +VALUES ('Admins', 'Administratoren der Anwendung'); + +INSERT INTO benutzer_gruppen (benutzer_id, gruppen_id) +VALUES (1, 1); + +INSERT INTO thema (kurztitel, titel, infotext, zusatztext) +VALUES +('TH1', 'Thema 1', 'Infotext zu Thema 1', 'Zusatztext zu Thema 1'), +('TH2', 'Thema 2', 'Infotext zu Thema 2', 'Zusatztext zu Thema 2'), +('TH3', 'Thema 3', 'Infotext zu Thema 3', 'Zusatztext zu Thema 3'); + +INSERT INTO fragen (themaid, text) +VALUES +(1, 'TH1 Nr 1'), (1, 'TH1 Nr 2'), (1, 'TH1 Nr 3'), (1, 'TH1 Nr 4'), +(1, 'TH1 Nr 5'), (1, 'TH1 Nr 6'), (1, 'TH1 Nr 7'), (1, 'TH1 Nr 8'), +(2, 'TH2 Nr 1'), (2, 'TH2 Nr 2'), (2, 'TH2 Nr 3'), (2, 'TH2 Nr 4'), +(2, 'TH2 Nr 5'), (2, 'TH2 Nr 6'), (2, 'TH2 Nr 7'), (2, 'TH2 Nr 8'), +(3, 'TH3 Nr 1'), (3, 'TH3 Nr 2'), (3, 'TH3 Nr 3'), (3, 'TH3 Nr 4'), +(3, 'TH3 Nr 5'), (3, 'TH3 Nr 6'), (3, 'TH3 Nr 7'), (3, 'TH3 Nr 8'); diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..63268ec --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,131 @@ +:root { + --bg: #f7f1e8; + --panel: #fffaf4; + --text: #3c332c; + --accent: #b7864f; + --accent-dark: #8f6437; + --border: #eadbc8; + --soft: #efe3d2; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: Arial, Helvetica, sans-serif; + color: var(--text); + background: linear-gradient(180deg, #fbf7f1, var(--bg)); +} +.container, .page-wrap { + width: min(1100px, calc(100% - 32px)); + margin: 0 auto; +} +.page-wrap { padding: 24px 0 48px; } +.site-header, .site-footer { + background: rgba(255,255,255,0.6); + backdrop-filter: blur(6px); + border-bottom: 1px solid var(--border); +} +.site-footer { border-top: 1px solid var(--border); border-bottom: 0; padding: 20px 0; margin-top: 32px; } +.nav-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 0; +} +.brand { + font-size: 1.4rem; + font-weight: 700; + color: var(--accent-dark); + text-decoration: none; +} +.main-nav { display: flex; gap: 16px; align-items: center; } +.main-nav a, .user-menu span { color: var(--text); text-decoration: none; } +.user-menu { position: relative; padding: 10px 14px; background: var(--panel); border-radius: 999px; border: 1px solid var(--border); } +.user-menu:hover .dropdown { display: block; } +.dropdown { + display: none; + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 180px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 18px; + overflow: hidden; +} +.dropdown a { display: block; padding: 12px 14px; } +.dropdown a:hover { background: var(--soft); } +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 26px; + padding: 24px; + margin-bottom: 20px; + box-shadow: 0 15px 40px rgba(80, 58, 34, 0.08); +} +.hero-card { padding: 40px 32px; } +.form-card { max-width: 560px; } +input[type="text"], input[type="email"], input[type="password"] { + width: 100%; + margin: 8px 0 18px; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--border); + background: #fff; +} +.btn { + display: inline-block; + padding: 12px 18px; + border-radius: 16px; + background: var(--accent); + color: #fff; + text-decoration: none; + border: 0; + cursor: pointer; +} +.btn-secondary { background: var(--accent-dark); } +.button-row { display: flex; flex-wrap: wrap; gap: 12px; } +.topic-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 18px; +} +.topic-box, .question-box, .contact-box { + background: #fff; + border: 1px solid var(--border); + border-radius: 20px; + padding: 18px; +} +.question-box { margin-bottom: 16px; } +.radio-row { display: flex; gap: 18px; } +.result-chart { width: 100%; max-width: 1000px; border-radius: 18px; border: 1px solid var(--border); } +.result-table { width: 100%; border-collapse: collapse; margin-top: 18px; } +.result-table th, .result-table td { padding: 10px; border-bottom: 1px solid var(--border); text-align: left; } +.flash-wrapper { margin-bottom: 16px; } +.flash { + padding: 14px 16px; + border-radius: 16px; + margin-bottom: 10px; + border: 1px solid var(--border); + background: #fff; +} +.flash.success { border-color: #9fc89f; } +.flash.warning { border-color: #e0b86d; } +.flash.danger { border-color: #d89f9f; } +.muted { color: #786a5d; } +@media (max-width: 700px) { + .nav-bar { flex-direction: column; gap: 12px; } +} + +.user-menu { + position: relative; + display: inline-block; +} + +.user-menu-dropdown { + position: absolute; + top: 100%; /* direkt unter dem Button */ + left: 0; + margin-top: 0; /* GANZ WICHTIG */ + padding-top: 0; /* falls vorhanden */ +} \ No newline at end of file diff --git a/templates/admin/contacts.html b/templates/admin/contacts.html new file mode 100644 index 0000000..bc55640 --- /dev/null +++ b/templates/admin/contacts.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block title %}Admin Ansprechpartner{% endblock %} +{% block content %} +
+

Ansprechpartner

+

CRUD folgt im nächsten Schritt. Aktuell Listenansicht als Platzhalter.

+
    + {% for item in contacts %} +
  • {{ item.name }} – {{ item.email }}
  • + {% endfor %} +
+
+{% endblock %} diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..95e68fa --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% block title %}Admin{% endblock %} +{% block content %} +
+

Admin Bereich

+ +
+{% endblock %} diff --git a/templates/admin/questions.html b/templates/admin/questions.html new file mode 100644 index 0000000..374bd10 --- /dev/null +++ b/templates/admin/questions.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block title %}Admin Fragen{% endblock %} +{% block content %} +
+

Fragen

+

CRUD folgt im nächsten Schritt. Aktuell Listenansicht als Platzhalter.

+
    + {% for item in fragen %} +
  • {{ item.kurztitel }} – {{ item.text }}
  • + {% endfor %} +
+
+{% endblock %} diff --git a/templates/admin/topics.html b/templates/admin/topics.html new file mode 100644 index 0000000..b790c3d --- /dev/null +++ b/templates/admin/topics.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block title %}Admin Themen{% endblock %} +{% block content %} +
+

Themen

+

CRUD folgt im nächsten Schritt. Aktuell Listenansicht als Platzhalter.

+
    + {% for item in themen %} +
  • {{ item.kurztitel }} – {{ item.titel }}
  • + {% endfor %} +
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f68c708 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,25 @@ + + + + + + {% block title %}dasunternehmen{% endblock %} + + + + {% include 'partials/menu.html' %} +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ {% include 'partials/footer.html' %} + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..f72d042 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} +{% block title %}Themen{% endblock %} +{% block content %} +
+

Themen

+
+ {% for item in themen %} +
+

{{ item.kurztitel }}

+

{{ item.titel }}

+

{{ item.infotext }}

+ Thema öffnen +
+ {% endfor %} +
+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..a978c9b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% block title %}Start{% endblock %} +{% block content %} +
+

Projekt dasunternehmen

+

Startgerüst für Registrierung, Login, Assessment, Ergebnisgrafik und Admin-Bereich.

+ +
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..e260ebf --- /dev/null +++ b/templates/login.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% block title %}Login{% endblock %} +{% block content %} +
+

Login

+
+ + + + + +
+
+{% endblock %} diff --git a/templates/partials/footer.html b/templates/partials/footer.html new file mode 100644 index 0000000..31a907b --- /dev/null +++ b/templates/partials/footer.html @@ -0,0 +1,5 @@ +
+
+

© dasunternehmen – Flask Projektgrundlage

+
+
diff --git a/templates/partials/menu.html b/templates/partials/menu.html new file mode 100644 index 0000000..4ed359f --- /dev/null +++ b/templates/partials/menu.html @@ -0,0 +1,23 @@ + diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..130e600 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% block title %}Profil{% endblock %} +{% block content %} +
+

Profil

+

Name: {{ user.name }}

+

E-Mail: {{ user.email }}

+
+ + + +
+
+{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..9a01758 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% block title %}Registrieren{% endblock %} +{% block content %} +
+

Registrierung

+
+ + + + + + + +
+
+{% endblock %} diff --git a/templates/result.html b/templates/result.html new file mode 100644 index 0000000..e2ce0cf --- /dev/null +++ b/templates/result.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} +{% block title %}Ergebnis{% endblock %} +{% block content %} +
+

Assessment Ergebnis

+ Assessment Grafik + + + + + + {% for row in rows %} + + {% endfor %} + +
ThemaJA Antworten
{{ row.kurztitel }}{{ row.ja_anzahl }}
+
+{% endblock %} diff --git a/templates/topic.html b/templates/topic.html new file mode 100644 index 0000000..4de188f --- /dev/null +++ b/templates/topic.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} +{% block title %}{{ thema.titel }}{% endblock %} +{% block content %} +
+

{{ thema.titel }}

+

{{ thema.infotext }}

+

{{ thema.zusatztext }}

+
+ +
+
+ + {% for frage in fragen %} +
+

{{ loop.index }}. {{ frage.text }}

+
+ + +
+
+ {% endfor %} + +
+
+ +
+

Ansprechpartner

+ {% for person in ansprechpartner %} +
+ {{ person.name }} +
{{ person.email }}
+

{{ person.infotext }}

+
+ {% else %} +

Aktuell keine Ansprechpartner hinterlegt.

+ {% endfor %} +
+{% endblock %} diff --git a/tools.py b/tools.py new file mode 100644 index 0000000..d7c7b19 --- /dev/null +++ b/tools.py @@ -0,0 +1,101 @@ +import io +import smtplib +import ssl +from email.message import EmailMessage +from itsdangerous import URLSafeTimedSerializer +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from config import Config + + +serializer = URLSafeTimedSerializer(Config.SECRET_KEY) + + +def generate_activation_token(email): + return serializer.dumps(email, salt='account-activation') + + + +def verify_activation_token(token, max_age=86400): + return serializer.loads(token, salt='account-activation', max_age=max_age) + + + +def send_mail(to_address, subject, body): + msg = EmailMessage() + msg['Subject'] = subject + msg['From'] = Config.MAIL_SENDER + msg['To'] = to_address + msg.set_content(body) + + if Config.SMTP_PORT == 465: + context = ssl.create_default_context() + with smtplib.SMTP_SSL(Config.SMTP_SERVER, Config.SMTP_PORT, context=context) as server: + if Config.SMTP_USERNAME: + server.login(Config.SMTP_USERNAME, Config.SMTP_PASSWORD) + server.send_message(msg) + return + + with smtplib.SMTP(Config.SMTP_SERVER, Config.SMTP_PORT) as server: + if Config.MAIL_USE_TLS: + server.starttls(context=ssl.create_default_context()) + if Config.SMTP_USERNAME: + server.login(Config.SMTP_USERNAME, Config.SMTP_PASSWORD) + server.send_message(msg) + + + +def create_assessment_chart(labels, yes_counts, output_path): + fig, ax = plt.subplots(figsize=(11, 5)) + x_values = list(range(len(labels))) + + ax.plot(x_values, yes_counts, marker='o') + + for x, y in zip(x_values, yes_counts): + if y >= 7: + color = 'green' + elif y >= 4: + color = 'orange' + else: + color = 'red' + ax.scatter([x], [y], color=color, s=120) + + ax.set_xticks(x_values) + ax.set_xticklabels(labels, rotation=20, ha='right') + ax.set_ylim(0, 8) + ax.set_ylabel('Anzahl JA-Antworten') + ax.set_xlabel('Themen') + ax.set_title('Assessment-Ergebnis') + ax.grid(True, axis='y', alpha=0.3) + fig.tight_layout() + fig.savefig(output_path, dpi=150) + plt.close(fig) + + + +def create_chart_bytes(labels, yes_counts): + buffer = io.BytesIO() + fig, ax = plt.subplots(figsize=(11, 5)) + x_values = list(range(len(labels))) + ax.plot(x_values, yes_counts, marker='o') + for x, y in zip(x_values, yes_counts): + if y >= 7: + color = 'green' + elif y >= 4: + color = 'orange' + else: + color = 'red' + ax.scatter([x], [y], color=color, s=120) + ax.set_xticks(x_values) + ax.set_xticklabels(labels, rotation=20, ha='right') + ax.set_ylim(0, 8) + ax.set_ylabel('Anzahl JA-Antworten') + ax.set_xlabel('Themen') + ax.set_title('Assessment-Ergebnis') + ax.grid(True, axis='y', alpha=0.3) + fig.tight_layout() + fig.savefig(buffer, format='png', dpi=150) + plt.close(fig) + buffer.seek(0) + return buffer