Initial commit

This commit is contained in:
Bernhard Kolb 2026-04-08 21:23:55 +02:00
commit 64876a81f4
27 changed files with 1156 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@ -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.*

12
Dockerfile Normal file
View File

@ -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"]

39
README.md Normal file
View File

@ -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ü

270
app.py Normal file
View File

@ -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/<token>')
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/<int:thema_id>', 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/<int:assessment_id>/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/<path:filename>')
@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)

19
config.py Normal file
View File

@ -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')

54
db.py Normal file
View File

@ -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()

72
deploy_flask.sh Normal file
View File

@ -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."

View File

@ -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:

38
permissions.py Normal file
View File

@ -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

6
requirements.txt Normal file
View File

@ -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

103
schema.sql Normal file
View File

@ -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');

131
static/css/style.css Normal file
View File

@ -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 */
}

View File

@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block title %}Admin Ansprechpartner{% endblock %}
{% block content %}
<section class="card">
<h1>Ansprechpartner</h1>
<p>CRUD folgt im nächsten Schritt. Aktuell Listenansicht als Platzhalter.</p>
<ul>
{% for item in contacts %}
<li>{{ item.name }} {{ item.email }}</li>
{% endfor %}
</ul>
</section>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}Admin{% endblock %}
{% block content %}
<section class="card">
<h1>Admin Bereich</h1>
<div class="button-row">
<a class="btn" href="{{ url_for('admin_topics') }}">Themen</a>
<a class="btn" href="{{ url_for('admin_questions') }}">Fragen</a>
<a class="btn" href="{{ url_for('admin_contacts') }}">Ansprechpartner</a>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block title %}Admin Fragen{% endblock %}
{% block content %}
<section class="card">
<h1>Fragen</h1>
<p>CRUD folgt im nächsten Schritt. Aktuell Listenansicht als Platzhalter.</p>
<ul>
{% for item in fragen %}
<li>{{ item.kurztitel }} {{ item.text }}</li>
{% endfor %}
</ul>
</section>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block title %}Admin Themen{% endblock %}
{% block content %}
<section class="card">
<h1>Themen</h1>
<p>CRUD folgt im nächsten Schritt. Aktuell Listenansicht als Platzhalter.</p>
<ul>
{% for item in themen %}
<li>{{ item.kurztitel }} {{ item.titel }}</li>
{% endfor %}
</ul>
</section>
{% endblock %}

25
templates/base.html Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}dasunternehmen{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
{% include 'partials/menu.html' %}
<main class="page-wrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-wrapper">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% include 'partials/footer.html' %}
</body>
</html>

17
templates/dashboard.html Normal file
View File

@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% block title %}Themen{% endblock %}
{% block content %}
<section class="card">
<h1>Themen</h1>
<div class="topic-grid">
{% for item in themen %}
<article class="topic-box">
<h2>{{ item.kurztitel }}</h2>
<h3>{{ item.titel }}</h3>
<p>{{ item.infotext }}</p>
<a class="btn" href="{{ url_for('topic', thema_id=item.id) }}">Thema öffnen</a>
</article>
{% endfor %}
</div>
</section>
{% endblock %}

12
templates/index.html Normal file
View File

@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}Start{% endblock %}
{% block content %}
<section class="card hero-card">
<h1>Projekt dasunternehmen</h1>
<p>Startgerüst für Registrierung, Login, Assessment, Ergebnisgrafik und Admin-Bereich.</p>
<div class="button-row">
<a class="btn" href="{{ url_for('register') }}">Registrieren</a>
<a class="btn btn-secondary" href="{{ url_for('login') }}">Login</a>
</div>
</section>
{% endblock %}

14
templates/login.html Normal file
View File

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}Login{% endblock %}
{% block content %}
<section class="card form-card">
<h1>Login</h1>
<form method="post">
<label>E-Mail</label>
<input type="email" name="email" required>
<label>Passwort</label>
<input type="password" name="password" required>
<button class="btn" type="submit">Anmelden</button>
</form>
</section>
{% endblock %}

View File

@ -0,0 +1,5 @@
<footer class="site-footer">
<div class="container">
<p>&copy; dasunternehmen Flask Projektgrundlage</p>
</div>
</footer>

View File

@ -0,0 +1,23 @@
<header class="site-header">
<div class="container nav-bar">
<a href="{{ url_for('index') }}" class="brand">dasunternehmen</a>
<nav class="main-nav">
{% if current_user %}
<a href="{{ url_for('dashboard') }}">Themen</a>
{% if session.get('user_id') %}
<div class="user-menu">
<span>{{ current_user.name }}</span>
<div class="dropdown">
<a href="{{ url_for('profile') }}">Profil</a>
{% if is_admin %}<a href="{{ url_for('admin_index') }}">Admin</a>{% endif %}
<a href="{{ url_for('logout') }}">Logout</a>
</div>
</div>
{% endif %}
{% else %}
<a href="{{ url_for('login') }}">Login</a>
<a href="{{ url_for('register') }}">Registrieren</a>
{% endif %}
</nav>
</div>
</header>

14
templates/profile.html Normal file
View File

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}Profil{% endblock %}
{% block content %}
<section class="card form-card">
<h1>Profil</h1>
<p><strong>Name:</strong> {{ user.name }}</p>
<p><strong>E-Mail:</strong> {{ user.email }}</p>
<form method="post">
<label>Neues Passwort</label>
<input type="password" name="password" required>
<button class="btn" type="submit">Passwort ändern</button>
</form>
</section>
{% endblock %}

16
templates/register.html Normal file
View File

@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% block title %}Registrieren{% endblock %}
{% block content %}
<section class="card form-card">
<h1>Registrierung</h1>
<form method="post">
<label>Name</label>
<input type="text" name="name" required>
<label>E-Mail</label>
<input type="email" name="email" required>
<label>Passwort</label>
<input type="password" name="password" required>
<button class="btn" type="submit">Konto anlegen</button>
</form>
</section>
{% endblock %}

18
templates/result.html Normal file
View File

@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% block title %}Ergebnis{% endblock %}
{% block content %}
<section class="card">
<h1>Assessment Ergebnis</h1>
<img class="result-chart" src="{{ url_for('generated_chart', filename=chart_file) }}" alt="Assessment Grafik">
<table class="result-table">
<thead>
<tr><th>Thema</th><th>JA Antworten</th></tr>
</thead>
<tbody>
{% for row in rows %}
<tr><td>{{ row.kurztitel }}</td><td>{{ row.ja_anzahl }}</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

38
templates/topic.html Normal file
View File

@ -0,0 +1,38 @@
{% extends 'base.html' %}
{% block title %}{{ thema.titel }}{% endblock %}
{% block content %}
<section class="card">
<h1>{{ thema.titel }}</h1>
<p>{{ thema.infotext }}</p>
<p class="muted">{{ thema.zusatztext }}</p>
</section>
<section class="card">
<form method="post">
<input type="hidden" name="assessment_id" value="{{ assessment_id }}">
{% for frage in fragen %}
<div class="question-box">
<p>{{ loop.index }}. {{ frage.text }}</p>
<div class="radio-row">
<label><input type="radio" name="frage_{{ frage.id }}" value="ja" required> Ja</label>
<label><input type="radio" name="frage_{{ frage.id }}" value="nein"> Nein</label>
</div>
</div>
{% endfor %}
<button class="btn" type="submit">Speichern und weiter</button>
</form>
</section>
<section class="card">
<h2>Ansprechpartner</h2>
{% for person in ansprechpartner %}
<div class="contact-box">
<strong>{{ person.name }}</strong>
<div>{{ person.email }}</div>
<p>{{ person.infotext }}</p>
</div>
{% else %}
<p>Aktuell keine Ansprechpartner hinterlegt.</p>
{% endfor %}
</section>
{% endblock %}

101
tools.py Normal file
View File

@ -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