diff --git a/app.py b/app.py index cb1fba0..9988c56 100644 --- a/app.py +++ b/app.py @@ -1,208 +1,240 @@ 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 db import ( + get_user_by_id, + get_user_by_email, + get_all_users, + create_user, + activate_user_by_email, + update_user_password, + update_user_last_login, + log_access, + get_user_groups, + create_assessment, + get_next_thema_id, + get_assessment_result_rows, + get_thema_questions, + get_thema_ansprechpartner, + save_assessment_answer, + get_all_themen, + get_thema_by_id, + get_all_ansprechpartner, + get_ansprechpartner_by_id, + get_ansprechpartner_ids_for_thema, + create_thema, + update_thema, + delete_thema, + create_ansprechpartner, + update_ansprechpartner, + delete_ansprechpartner, + get_all_questions_with_thema, + get_all_contacts, + get_question_by_id, + create_question, + update_question, + delete_question, + activate_user, + delete_user, +) 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 = 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} + user_is_admin = False + + if session.get("user_id"): + user = get_user_by_id(session["user_id"]) + groups = get_user_groups(session["user_id"]) + session["groups"] = groups + user_is_admin = "Admins" in groups + + return { + "current_user": user, + "is_admin": user_is_admin, + } -@app.route('/') +@app.route("/") def index(): - if session.get('user_id'): - return redirect(url_for('dashboard')) - return render_template('index.html') + if session.get("user_id"): + return redirect(url_for("dashboard")) + return render_template("index.html") -@app.route('/register', methods=['GET', 'POST']) +@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'] + 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,)) + existing = get_user_by_email(email) if existing: - flash('E-Mail ist bereits registriert.', 'danger') - return render_template('register.html') + 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), - ) + create_user(name, email, password_hash, is_active=False) 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', + "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') + flash("Registrierung gespeichert. Bitte E-Mail zur Aktivierung prüfen.", "success") + return redirect(url_for("login")) + + return render_template("register.html") -@app.route('/activate/') +@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')) + 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')) + activate_user_by_email(email) + flash("Account wurde aktiviert. Bitte anmelden.", "success") + return redirect(url_for("login")) -@app.route('/login', methods=['GET', 'POST']) +@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 request.method == "POST": + email = request.form["email"].strip().lower() + password = request.form["password"] + + user = get_user_by_email(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') + flash("Ungültige Zugangsdaten.", "danger") + return render_template("login.html") - stored_password = user['passwort_hash'] + 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': + + 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'])) + update_user_password(user["id"], new_hash) 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') + 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') + session["user_id"] = user["id"] + session["groups"] = get_user_groups(user["id"]) + + update_user_last_login(user["id"]) + log_access(user["id"]) + + return redirect(url_for("dashboard")) + + return render_template("login.html") -@app.route('/logout') +@app.route("/logout") def logout(): session.clear() - flash('Erfolgreich abgemeldet.', 'success') - return redirect(url_for('login')) + flash("Erfolgreich abgemeldet.", "success") + return redirect(url_for("login")) -@app.route('/dashboard') +@app.route("/dashboard") @login_required def dashboard(): - themen = fetch_all('SELECT * FROM thema ORDER BY id') - return render_template('dashboard.html', themen=themen) + themen = get_all_themen() + return render_template("dashboard.html", themen=themen) -@app.route('/profil', methods=['GET', 'POST']) +@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) + user = get_user_by_id(session["user_id"]) + + if request.method == "POST": + new_password = request.form["password"].strip() + if not new_password: + flash("Bitte ein Passwort eingeben.", "warning") + return render_template("profile.html", user=user) + + update_user_password(session["user_id"], generate_password_hash(new_password)) + flash("Passwort wurde geändert.", "success") + return redirect(url_for("profile")) + + return render_template("profile.html", user=user) -@app.route('/thema/', methods=['GET', 'POST']) +@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,), - ) + thema = get_thema_by_id(thema_id) + if not thema: + flash("Thema nicht gefunden.", "danger") + return redirect(url_for("dashboard")) + + fragen = get_thema_questions(thema_id) + ansprechpartner = get_thema_ansprechpartner(thema_id) + + if request.method == "POST": + assessment_id = request.form.get("assessment_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'] + assessment_id = create_assessment(session["user_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') + if value not in ("ja", "nein"): + flash("Bitte alle Fragen beantworten.", "warning") return render_template( - 'topic.html', + "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'), + + save_assessment_answer( + assessment_id=assessment_id, + thema_id=thema_id, + frage_id=frage["id"], + antwort=(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)) + next_thema_id = get_next_thema_id(thema_id) + if next_thema_id: + return redirect(url_for("topic", thema_id=next_thema_id, assessment_id=assessment_id)) - assessment_id = request.args.get('assessment_id', '') + return redirect(url_for("assessment_result", assessment_id=assessment_id)) + + assessment_id = request.args.get("assessment_id", "") return render_template( - 'topic.html', + "topic.html", thema=thema, fragen=fragen, ansprechpartner=ansprechpartner, @@ -210,61 +242,328 @@ def topic(thema_id): ) -@app.route('/assessment//result') +@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' + rows = get_assessment_result_rows(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) + + return render_template( + "result.html", + assessment_id=assessment_id, + chart_file=filename, + rows=rows, + ) -@app.route('/generated_charts/') +@app.route("/generated_charts/") @login_required def generated_chart(filename): return send_from_directory(chart_dir, filename) -@app.route('/admin') +@app.route("/admin") @admin_required def admin_index(): - return render_template('admin/index.html') + return render_template("admin/index.html") -@app.route('/admin/themen') +@app.route("/admin/user") @admin_required -def admin_topics(): - themen = fetch_all('SELECT * FROM thema ORDER BY id') - return render_template('admin/topics.html', themen=themen) +def admin_users(): + users = get_all_users() + return render_template("admin/users.html", users=users) -@app.route('/admin/fragen') +@app.route("/admin/themen") @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''' +def admin_themen(): + themen = get_all_themen() + return render_template("admin/themen_list.html", themen=themen) + + +@app.route("/admin/themen/new", methods=["GET", "POST"]) +@admin_required +def admin_thema_new(): + ansprechpartner = get_all_ansprechpartner() + + if request.method == "POST": + kurztitel = request.form.get("kurztitel", "").strip() + titel = request.form.get("titel", "").strip() + infotext = request.form.get("infotext", "").strip() + zusatztext = request.form.get("zusatztext", "").strip() + ansprechpartner_ids = [int(x) for x in request.form.getlist("ansprechpartner_ids")] + + if not kurztitel or not titel: + flash("Kurztitel und Titel sind Pflichtfelder.", "error") + return render_template( + "admin/thema_form.html", + mode="new", + thema=request.form, + ansprechpartner=ansprechpartner, + selected_ansprechpartner_ids=ansprechpartner_ids, + ) + + create_thema(kurztitel, titel, infotext, zusatztext, ansprechpartner_ids) + flash("Thema wurde erstellt.", "success") + return redirect(url_for("admin_themen")) + + return render_template( + "admin/thema_form.html", + mode="new", + thema={}, + ansprechpartner=ansprechpartner, + selected_ansprechpartner_ids=[], ) - return render_template('admin/questions.html', fragen=fragen) -@app.route('/admin/ansprechpartner') +@app.route("/admin/themen//edit", methods=["GET", "POST"]) +@admin_required +def admin_thema_edit(thema_id): + thema = get_thema_by_id(thema_id) + if not thema: + flash("Thema nicht gefunden.", "error") + return redirect(url_for("admin_themen")) + + ansprechpartner = get_all_ansprechpartner() + + if request.method == "POST": + kurztitel = request.form.get("kurztitel", "").strip() + titel = request.form.get("titel", "").strip() + infotext = request.form.get("infotext", "").strip() + zusatztext = request.form.get("zusatztext", "").strip() + ansprechpartner_ids = [int(x) for x in request.form.getlist("ansprechpartner_ids")] + + if not kurztitel or not titel: + flash("Kurztitel und Titel sind Pflichtfelder.", "error") + thema_form = { + "id": thema_id, + "kurztitel": kurztitel, + "titel": titel, + "infotext": infotext, + "zusatztext": zusatztext, + } + return render_template( + "admin/thema_form.html", + mode="edit", + thema=thema_form, + ansprechpartner=ansprechpartner, + selected_ansprechpartner_ids=ansprechpartner_ids, + ) + + update_thema(thema_id, kurztitel, titel, infotext, zusatztext, ansprechpartner_ids) + flash("Thema wurde gespeichert.", "success") + return redirect(url_for("admin_themen")) + + selected_ansprechpartner_ids = get_ansprechpartner_ids_for_thema(thema_id) + + return render_template( + "admin/thema_form.html", + mode="edit", + thema=thema, + ansprechpartner=ansprechpartner, + selected_ansprechpartner_ids=selected_ansprechpartner_ids, + ) + + +@app.route("/admin/themen//delete", methods=["POST"]) +@admin_required +def admin_thema_delete(thema_id): + delete_thema(thema_id) + flash("Thema wurde gelöscht.", "success") + return redirect(url_for("admin_themen")) + + + +@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) + contacts = get_all_contacts() + return render_template("admin/contacts.html", contacts=contacts) -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=True) +@app.route("/admin/ansprechpartner/new", methods=["GET", "POST"]) +@admin_required +def admin_contact_new(): + if request.method == "POST": + name = request.form.get("name", "").strip() + email = request.form.get("email", "").strip() + infotext = request.form.get("infotext", "").strip() + + if not name or not email: + flash("Name und E-Mail sind Pflichtfelder.", "error") + return render_template( + "admin/contact_form.html", + mode="new", + contact=request.form, + ) + + create_ansprechpartner(name, email, infotext) + flash("Ansprechpartner wurde erstellt.", "success") + return redirect(url_for("admin_contacts")) + + return render_template( + "admin/contact_form.html", + mode="new", + contact={}, + ) + + +@app.route("/admin/ansprechpartner//edit", methods=["GET", "POST"]) +@admin_required +def admin_contact_edit(ansprechpartner_id): + contact = get_ansprechpartner_by_id(ansprechpartner_id) + if not contact: + flash("Ansprechpartner nicht gefunden.", "error") + return redirect(url_for("admin_contacts")) + + if request.method == "POST": + name = request.form.get("name", "").strip() + email = request.form.get("email", "").strip() + infotext = request.form.get("infotext", "").strip() + + if not name or not email: + flash("Name und E-Mail sind Pflichtfelder.", "error") + contact_form = { + "id": ansprechpartner_id, + "name": name, + "email": email, + "infotext": infotext, + } + return render_template( + "admin/contact_form.html", + mode="edit", + contact=contact_form, + ) + + update_ansprechpartner(ansprechpartner_id, name, email, infotext) + flash("Ansprechpartner wurde gespeichert.", "success") + return redirect(url_for("admin_contacts")) + + return render_template( + "admin/contact_form.html", + mode="edit", + contact=contact, + ) + + +@app.route("/admin/ansprechpartner//delete", methods=["POST"]) +@admin_required +def admin_contact_delete(ansprechpartner_id): + delete_ansprechpartner(ansprechpartner_id) + flash("Ansprechpartner wurde gelöscht.", "success") + return redirect(url_for("admin_contacts")) + +@app.route("/admin/fragen") +@admin_required +def admin_questions(): + fragen = get_all_questions_with_thema() + themen = get_all_themen() + return render_template("admin/questions.html", fragen=fragen, themen=themen) + +@app.route("/admin/fragen/new", methods=["GET", "POST"]) +@admin_required +def admin_question_new(): + themen = get_all_themen() + + if request.method == "POST": + thema_id = request.form.get("thema_id") + text = request.form.get("text", "").strip() + + if not thema_id or not text: + flash("Thema und Text sind Pflichtfelder.", "error") + return render_template( + "admin/question_form.html", + mode="new", + frage=request.form, + themen=themen, + ) + + create_question(thema_id, text) + flash("Frage wurde erstellt.", "success") + return redirect(url_for("admin_questions")) + + return render_template( + "admin/question_form.html", + mode="new", + frage={}, + themen=themen, + ) + +@app.route("/admin/fragen//edit", methods=["GET", "POST"]) +@admin_required +def admin_question_edit(frage_id): + frage = get_question_by_id(frage_id) + themen = get_all_themen() + + if not frage: + flash("Frage nicht gefunden.", "error") + return redirect(url_for("admin_questions")) + + if request.method == "POST": + thema_id = request.form.get("thema_id") + text = request.form.get("text", "").strip() + + if not thema_id or not text: + frage_form = { + "id": frage_id, + "thema_id": thema_id, + "text": text, + } + flash("Thema und Text sind Pflichtfelder.", "error") + return render_template( + "admin/question_form.html", + mode="edit", + frage=frage_form, + themen=themen, + ) + + update_question(frage_id, thema_id, text) + flash("Frage wurde gespeichert.", "success") + return redirect(url_for("admin_questions")) + + return render_template( + "admin/question_form.html", + mode="edit", + frage=frage, + themen=themen, + ) + +@app.route("/admin/fragen//delete", methods=["POST"]) +@admin_required +def admin_question_delete(frage_id): + delete_question(frage_id) + flash("Frage wurde gelöscht.", "success") + return redirect(url_for("admin_questions")) + +@app.route("/admin/user//activate", methods=["POST"]) +@admin_required +def admin_user_activate(user_id): + if user_id == session.get("user_id"): + flash("Der aktuell angemeldete Benutzer ist bereits aktiv.", "warning") + return redirect(url_for("admin_users")) + + activate_user(user_id) + flash("Benutzer wurde aktiviert.", "success") + return redirect(url_for("admin_users")) + + +@app.route("/admin/user//delete", methods=["POST"]) +@admin_required +def admin_user_delete(user_id): + if user_id == session.get("user_id"): + flash("Du kannst deinen eigenen Benutzer nicht löschen.", "danger") + return redirect(url_for("admin_users")) + + delete_user(user_id) + flash("Benutzer wurde gelöscht.", "success") + return redirect(url_for("admin_users")) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=True) \ No newline at end of file diff --git a/config.py b/config.py index 82d8462..95db80d 100644 --- a/config.py +++ b/config.py @@ -9,11 +9,11 @@ class Config: 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_SERVER = os.getenv('SMTP_SERVER', 'mail.kolb.cc') + SMTP_PORT = int(os.getenv('SMTP_PORT', '25')) 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' + MAIL_SENDER = os.getenv('MAIL_SENDER', 'admin@kolb.cc') + MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'false').lower() == 'true' - APP_BASE_URL = os.getenv('APP_BASE_URL', 'http://localhost:5050') + APP_BASE_URL = os.getenv('APP_BASE_URL', 'http://localhost:8086') diff --git a/db.py b/db.py index 51f654b..55f3f87 100644 --- a/db.py +++ b/db.py @@ -52,3 +52,444 @@ def execute_returning(query, params=None): with get_cursor(commit=True) as cur: cur.execute(query, params or ()) return cur.fetchone() + + +############### +# Benutzer +############### + +def get_user_by_id(user_id): + return fetch_one( + """ + SELECT id, name, email, passwort_hash, is_active, last_login + FROM benutzer + WHERE id = %s + """, + (user_id,), + ) + + +def get_user_by_email(email): + return fetch_one( + """ + SELECT id, name, email, passwort_hash, is_active, last_login + FROM benutzer + WHERE email = %s + """, + (email,), + ) + + +def get_all_users(): + return fetch_all( + """ + SELECT id, name, email, last_login, is_active + FROM benutzer + ORDER BY name, email + """ + ) + + +def create_user(name, email, password_hash, is_active=False): + return execute_returning( + """ + INSERT INTO benutzer (name, email, passwort_hash, is_active) + VALUES (%s, %s, %s, %s) + RETURNING id + """, + (name, email, password_hash, is_active), + ) + + +def activate_user_by_email(email): + execute( + """ + UPDATE benutzer + SET is_active = TRUE + WHERE email = %s + """, + (email,), + ) + + +def update_user_password(user_id, password_hash): + execute( + """ + UPDATE benutzer + SET passwort_hash = %s + WHERE id = %s + """, + (password_hash, user_id), + ) + + +def update_user_last_login(user_id): + execute( + """ + UPDATE benutzer + SET last_login = NOW() + WHERE id = %s + """, + (user_id,), + ) + + +def log_access(user_id): + execute( + """ + INSERT INTO accesslog (user_id) + VALUES (%s) + """, + (user_id,), + ) + + +def get_user_groups(user_id): + rows = fetch_all( + """ + SELECT g.gruppenname + FROM benutzer_gruppen bg + JOIN gruppen g ON g.id = bg.gruppen_id + WHERE bg.benutzer_id = %s + ORDER BY g.gruppenname + """, + (user_id,), + ) + return [row["gruppenname"] for row in rows] + + +############### +# Themen +############### + +def get_all_themen(): + return fetch_all( + """ + SELECT id, kurztitel, titel, infotext, zusatztext + FROM thema + ORDER BY id + """ + ) + + +def get_thema_by_id(thema_id): + return fetch_one( + """ + SELECT id, kurztitel, titel, infotext, zusatztext + FROM thema + WHERE id = %s + """, + (thema_id,), + ) + + +def get_next_thema_id(current_thema_id): + row = fetch_one( + """ + SELECT id + FROM thema + WHERE id > %s + ORDER BY id + LIMIT 1 + """, + (current_thema_id,), + ) + return row["id"] if row else None + + +def get_all_ansprechpartner(): + return fetch_all( + """ + SELECT id, name, email, infotext + FROM ansprechpartner + ORDER BY name ASC + """ + ) + + +def get_all_contacts(): + return fetch_all( + """ + SELECT id, name, email, infotext + FROM ansprechpartner + ORDER BY name ASC + """ + ) + + +def get_thema_ansprechpartner(thema_id): + return fetch_all( + """ + SELECT a.id, a.name, a.email, a.infotext + FROM ansprechpartner a + JOIN themaansprechpartner ta ON ta.ansprechpartner_id = a.id + WHERE ta.thema_id = %s + ORDER BY a.name ASC + """, + (thema_id,), + ) + + +def get_ansprechpartner_ids_for_thema(thema_id): + rows = fetch_all( + """ + SELECT ansprechpartner_id + FROM themaansprechpartner + WHERE thema_id = %s + """, + (thema_id,), + ) + return [row["ansprechpartner_id"] for row in rows] + + +def create_thema(kurztitel, titel, infotext, zusatztext, ansprechpartner_ids): + row = execute_returning( + """ + INSERT INTO thema (kurztitel, titel, infotext, zusatztext) + VALUES (%s, %s, %s, %s) + RETURNING id + """, + (kurztitel, titel, infotext, zusatztext), + ) + thema_id = row["id"] + + for ap_id in ansprechpartner_ids: + execute( + """ + INSERT INTO themaansprechpartner (thema_id, ansprechpartner_id) + VALUES (%s, %s) + """, + (thema_id, ap_id), + ) + + return thema_id + + +def update_thema(thema_id, kurztitel, titel, infotext, zusatztext, ansprechpartner_ids): + execute( + """ + UPDATE thema + SET kurztitel = %s, + titel = %s, + infotext = %s, + zusatztext = %s + WHERE id = %s + """, + (kurztitel, titel, infotext, zusatztext, thema_id), + ) + + execute( + """ + DELETE FROM themaansprechpartner + WHERE thema_id = %s + """, + (thema_id,), + ) + + for ap_id in ansprechpartner_ids: + execute( + """ + INSERT INTO themaansprechpartner (thema_id, ansprechpartner_id) + VALUES (%s, %s) + """, + (thema_id, ap_id), + ) + + +def delete_thema(thema_id): + execute("DELETE FROM themaansprechpartner WHERE thema_id = %s", (thema_id,)) + execute("DELETE FROM fragen WHERE thema_id = %s", (thema_id,)) + execute("DELETE FROM thema WHERE id = %s", (thema_id,)) + + +############### +# Fragen +############### + +def get_thema_questions(thema_id): + return fetch_all( + """ + SELECT id, thema_id, text + FROM fragen + WHERE thema_id = %s + ORDER BY id + """, + (thema_id,), + ) + + +def get_all_questions_with_thema(): + return fetch_all( + """ + SELECT f.id, f.text, f.thema_id, t.kurztitel, t.titel + FROM fragen f + JOIN thema t ON t.id = f.thema_id + ORDER BY t.id, f.id + """ + ) + + +############### +# Assessment +############### + +def create_assessment(user_id): + row = execute_returning( + """ + INSERT INTO assessment (user_id) + VALUES (%s) + RETURNING id + """, + (user_id,), + ) + return row["id"] + + +def save_assessment_answer(assessment_id, thema_id, frage_id, antwort): + execute( + """ + INSERT INTO assessmentanswer (assessmentid, thema_id, frage_id, antwort) + VALUES (%s, %s, %s, %s) + ON CONFLICT (assessmentid, frage_id) + DO UPDATE SET antwort = EXCLUDED.antwort + """, + (assessment_id, thema_id, frage_id, antwort), + ) + + +def get_assessment_result_rows(assessment_id): + return fetch_all( + """ + SELECT t.kurztitel, COUNT(*) FILTER (WHERE aa.antwort = TRUE) AS ja_anzahl + FROM thema t + LEFT JOIN assessmentanswer aa + ON aa.thema_id = t.id + AND aa.assessmentid = %s + GROUP BY t.id, t.kurztitel + ORDER BY t.id + """, + (assessment_id,), + ) +############ +# Ansprechpartner +############ + +def get_ansprechpartner_by_id(ansprechpartner_id): + return fetch_one( + """ + SELECT id, name, email, infotext + FROM ansprechpartner + WHERE id = %s + """, + (ansprechpartner_id,), + ) + + +def create_ansprechpartner(name, email, infotext): + row = execute_returning( + """ + INSERT INTO ansprechpartner (name, email, infotext) + VALUES (%s, %s, %s) + RETURNING id + """, + (name, email, infotext), + ) + return row["id"] + + +def update_ansprechpartner(ansprechpartner_id, name, email, infotext): + execute( + """ + UPDATE ansprechpartner + SET name = %s, + email = %s, + infotext = %s + WHERE id = %s + """, + (name, email, infotext, ansprechpartner_id), + ) + + +def delete_ansprechpartner(ansprechpartner_id): + execute( + """ + DELETE FROM themaansprechpartner + WHERE ansprechpartner_id = %s + """, + (ansprechpartner_id,), + ) + execute( + """ + DELETE FROM ansprechpartner + WHERE id = %s + """, + (ansprechpartner_id,), + ) + +############### +# Fragen +############### +def get_question_by_id(frage_id): + return fetch_one( + """ + SELECT id, thema_id, text + FROM fragen + WHERE id = %s + """, + (frage_id,), + ) + + +def create_question(thema_id, text): + return execute_returning( + """ + INSERT INTO fragen (thema_id, text) + VALUES (%s, %s) + RETURNING id + """, + (thema_id, text), + ) + + +def update_question(frage_id, thema_id, text): + execute( + """ + UPDATE fragen + SET thema_id = %s, + text = %s + WHERE id = %s + """, + (thema_id, text, frage_id), + ) + + +def delete_question(frage_id): + execute( + "DELETE FROM fragen WHERE id = %s", + (frage_id,), + ) + +########## +# usermanagement +########## +def activate_user(user_id): + execute( + """ + UPDATE benutzer + SET is_active = TRUE + WHERE id = %s + """, + (user_id,), + ) + + +def delete_user(user_id, current_user_id=None): + if current_user_id is not None and int(user_id) == int(current_user_id): + raise ValueError("Der aktuell angemeldete Benutzer kann nicht gelöscht werden.") + execute( + """ + DELETE FROM benutzer + WHERE id = %s + """, + (user_id,), + ) \ No newline at end of file diff --git a/permissions.py b/permissions.py index 53750a2..d1a6d2c 100644 --- a/permissions.py +++ b/permissions.py @@ -1,38 +1,23 @@ from functools import wraps -from flask import session, redirect, url_for, flash -from db import fetch_one +from flask import flash, redirect, session, url_for -def login_required(view_func): - @wraps(view_func) +def login_required(func): + @wraps(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) + if not session.get("user_id"): + flash("Bitte zuerst anmelden.", "warning") + return redirect(url_for("login")) + return func(*args, **kwargs) return wrapper - -def admin_required(view_func): - @wraps(view_func) +def admin_required(func): + @wraps(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 + groups = session.get("groups", []) + if "Admins" not in groups: + flash("Keine Berechtigung.", "danger") + return redirect(url_for("dashboard")) + return func(*args, **kwargs) + return wrapper \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 63268ec..6ec4ac1 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -9,52 +9,107 @@ } * { 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; } + +.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; } + +.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; } + +.main-nav { + display: flex; + gap: 16px; + align-items: center; +} + +.main-nav a { + color: var(--text); + text-decoration: none; +} + +.user-menu { + position: relative; + display: inline-block; + padding: 10px 14px; + background: var(--panel); + border-radius: 999px; + border: 1px solid var(--border); +} + +.user-menu span, +.user-menu > a { + color: var(--text); + text-decoration: none; +} + +.user-menu:hover .dropdown, +.user-menu:focus-within .dropdown { + display: block; +} + .dropdown { display: none; position: absolute; - top: calc(100% + 8px); + top: 100%; right: 0; - min-width: 180px; + margin-top: 0; + min-width: 220px; background: var(--panel); border: 1px solid var(--border); - border-radius: 18px; + border-radius: 20px; overflow: hidden; + z-index: 1000; } -.dropdown a { display: block; padding: 12px 14px; } -.dropdown a:hover { background: var(--soft); } + +.dropdown a { + display: block; + padding: 14px 16px; + color: var(--text); + text-decoration: none; + white-space: nowrap; +} + +.dropdown a:hover { + background: var(--soft); +} + .card { background: var(--panel); border: 1px solid var(--border); @@ -63,9 +118,19 @@ body { 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"] { + +.hero-card { + padding: 40px 32px; +} + +.form-card { + max-width: 560px; +} + +input[type="text"], +input[type="email"], +input[type="password"], +textarea { width: 100%; margin: 8px 0 18px; padding: 14px 16px; @@ -73,35 +138,92 @@ input[type="text"], input[type="email"], input[type="password"] { border: 1px solid var(--border); background: #fff; } + .btn { display: inline-block; - padding: 12px 18px; - border-radius: 16px; - background: var(--accent); - color: #fff; + padding: 10px 18px; + border: 1px solid #cdb693; + border-radius: 999px; + background: #efe3d1; + color: #3f342c; text-decoration: none; - border: 0; cursor: pointer; } -.btn-secondary { background: var(--accent-dark); } -.button-row { display: flex; flex-wrap: wrap; gap: 12px; } + +.btn-secondary { + background: #f7f3ee; +} + +.btn-danger { + background: #d86a5f; + border-color: #d86a5f; + color: #fff; +} + +.btn-small { + padding: 8px 14px; + font-size: 14px; +} + +.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 { + +.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; } + +.topic-box { + text-decoration: none; + color: var(--text); +} + +.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; @@ -109,23 +231,87 @@ input[type="text"], input[type="email"], input[type="password"] { 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; } +.flash.danger, +.flash.error { border-color: #d89f9f; } + +.muted { + color: #786a5d; +} + +.content-card { + background: #fff; + border: 1px solid #dccdb7; + border-radius: 28px; + padding: 24px; + box-shadow: 0 8px 24px rgba(0,0,0,0.05); +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} + +.admin-table { + width: 100%; + border-collapse: collapse; +} + +.admin-table th, +.admin-table td { + padding: 12px 10px; + border-bottom: 1px solid #e7dccd; + text-align: left; + vertical-align: top; +} + +.actions { + white-space: nowrap; +} + +.admin-form .form-group { + margin-bottom: 18px; +} + +.admin-form textarea { + min-height: 120px; + resize: vertical; +} + +.checkbox-list { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid #d8c7ae; + border-radius: 16px; + background: #fffdf9; +} + +.checkbox-item { + display: flex; + align-items: center; + gap: 10px; +} + +.form-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} + @media (max-width: 700px) { - .nav-bar { flex-direction: column; gap: 12px; } -} + .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 */ + .page-header { + flex-direction: column; + align-items: flex-start; + } } \ No newline at end of file diff --git a/templates/admin/contact_form.html b/templates/admin/contact_form.html new file mode 100644 index 0000000..d46f42a --- /dev/null +++ b/templates/admin/contact_form.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block content %} +
+

+ {% if mode == "edit" %} + Ansprechpartner bearbeiten + {% else %} + Neuen Ansprechpartner erstellen + {% endif %} +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Abbrechen +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/contacts.html b/templates/admin/contacts.html index bc55640..6a9653b 100644 --- a/templates/admin/contacts.html +++ b/templates/admin/contacts.html @@ -1,13 +1,46 @@ -{% extends 'base.html' %} -{% block title %}Admin Ansprechpartner{% endblock %} +{% extends "base.html" %} + {% block content %} -
-

Ansprechpartner

-

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

-
    - {% for item in contacts %} -
  • {{ item.name }} – {{ item.email }}
  • - {% endfor %} -
-
-{% endblock %} +
+ + + {% if contacts %} + + + + + + + + + + + + {% for contact in contacts %} + + + + + + + + {% endfor %} + +
IDNameE-MailInfoAktionen
{{ contact.id }}{{ contact.name }}{{ contact.email }}{{ contact.infotext or "" }} + Bearbeiten + +
+ +
+
+ {% else %} +

Keine Ansprechpartner vorhanden.

+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/index.html b/templates/admin/index.html index 95e68fa..1f4f08d 100644 --- a/templates/admin/index.html +++ b/templates/admin/index.html @@ -1,12 +1,32 @@ -{% extends 'base.html' %} -{% block title %}Admin{% endblock %} +{% extends "base.html" %} + {% block content %} -
-

Admin Bereich

-
-{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/question_form.html b/templates/admin/question_form.html new file mode 100644 index 0000000..7063703 --- /dev/null +++ b/templates/admin/question_form.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block content %} +
+

+ {% if mode == "edit" %} + Frage bearbeiten + {% else %} + Neue Frage + {% endif %} +

+ +
+ +
+ + +
+ +
+ + +
+ +
+ + Abbrechen +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/questions.html b/templates/admin/questions.html index 374bd10..c14b960 100644 --- a/templates/admin/questions.html +++ b/templates/admin/questions.html @@ -1,13 +1,41 @@ -{% extends 'base.html' %} -{% block title %}Admin Fragen{% endblock %} +{% extends "base.html" %} + {% block content %} -
-

Fragen

-

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

-
    - {% for item in fragen %} -
  • {{ item.kurztitel }} – {{ item.text }}
  • - {% endfor %} -
-
-{% endblock %} +
+ + + + + + + + + + + + + {% for f in fragen %} + + + + + + + {% endfor %} + +
IDThemaFrageAktionen
{{ f.id }}{{ f.kurztitel }}{{ f.text }} + Bearbeiten + +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/thema_form.html b/templates/admin/thema_form.html new file mode 100644 index 0000000..a430385 --- /dev/null +++ b/templates/admin/thema_form.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} + +{% block content %} +
+

+ {% if mode == "edit" %} + Thema bearbeiten + {% else %} + Neues Thema erstellen + {% endif %} +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ {% for ap in ansprechpartner %} + + {% endfor %} +
+
+ +
+ + Abbrechen +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/themen_list.html b/templates/admin/themen_list.html new file mode 100644 index 0000000..6d180ff --- /dev/null +++ b/templates/admin/themen_list.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block content %} +
+ + + {% if themen %} + + + + + + + + + + + {% for thema in themen %} + + + + + + + {% endfor %} + +
IDKurztitelTitelAktionen
{{ thema.id }}{{ thema.kurztitel }}{{ thema.titel }} + Bearbeiten + +
+ +
+
+ {% else %} +

Keine Themen vorhanden.

+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/users.html b/templates/admin/users.html new file mode 100644 index 0000000..6e44469 --- /dev/null +++ b/templates/admin/users.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} + +{% block content %} +
+ + + {% if users %} + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% endfor %} + +
IDNameE-MailAktivLetzter LoginAktionen
{{ user.id }}{{ user.name }}{{ user.email }}{{ "Ja" if user.is_active else "Nein" }}{{ user.last_login or "-" }} + {% if not user.is_active %} +
+ +
+ {% endif %} + + {% if user.id != session.get('user_id') %} +
+ +
+ {% else %} + Aktueller Benutzer + {% endif %} +
+ {% else %} +

Keine Benutzer vorhanden.

+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/partials/menu.html b/templates/partials/menu.html index 4ed359f..552568d 100644 --- a/templates/partials/menu.html +++ b/templates/partials/menu.html @@ -1,23 +1,28 @@ + \ No newline at end of file diff --git a/tools.py b/tools.py index d7c7b19..829c184 100644 --- a/tools.py +++ b/tools.py @@ -29,6 +29,7 @@ def send_mail(to_address, subject, body): msg['To'] = to_address msg.set_content(body) + # SMTPS if Config.SMTP_PORT == 465: context = ssl.create_default_context() with smtplib.SMTP_SSL(Config.SMTP_SERVER, Config.SMTP_PORT, context=context) as server: @@ -37,11 +38,14 @@ def send_mail(to_address, subject, body): server.send_message(msg) return + # Normales SMTP, z. B. Port 25 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)