Initial commit
This commit is contained in:
commit
64876a81f4
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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
12
Dockerfile
Normal 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
39
README.md
Normal 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
270
app.py
Normal 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
19
config.py
Normal 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
54
db.py
Normal 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
72
deploy_flask.sh
Normal 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."
|
||||||
39
docker-compose.example.yaml
Normal file
39
docker-compose.example.yaml
Normal 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
38
permissions.py
Normal 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
6
requirements.txt
Normal 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
103
schema.sql
Normal 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
131
static/css/style.css
Normal 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 */
|
||||||
|
}
|
||||||
13
templates/admin/contacts.html
Normal file
13
templates/admin/contacts.html
Normal 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 %}
|
||||||
12
templates/admin/index.html
Normal file
12
templates/admin/index.html
Normal 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 %}
|
||||||
13
templates/admin/questions.html
Normal file
13
templates/admin/questions.html
Normal 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 %}
|
||||||
13
templates/admin/topics.html
Normal file
13
templates/admin/topics.html
Normal 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
25
templates/base.html
Normal 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
17
templates/dashboard.html
Normal 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
12
templates/index.html
Normal 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
14
templates/login.html
Normal 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 %}
|
||||||
5
templates/partials/footer.html
Normal file
5
templates/partials/footer.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<p>© dasunternehmen – Flask Projektgrundlage</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
23
templates/partials/menu.html
Normal file
23
templates/partials/menu.html
Normal 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
14
templates/profile.html
Normal 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
16
templates/register.html
Normal 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
18
templates/result.html
Normal 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
38
templates/topic.html
Normal 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
101
tools.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user