feat: add landing page, enable mail-to-terminal, show success message
Some checks failed
Docker Build (PR) / Build Docker image (pull_request) Failing after 3m19s

closes #11
This commit is contained in:
2025-11-19 13:22:30 +01:00
parent 5e37b57c9b
commit 4738732517
4 changed files with 531 additions and 50 deletions

View File

@@ -2,16 +2,13 @@ import os
import re import re
import smtplib import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from typing import Optional
from xml.etree.ElementTree import Element, SubElement, tostring from xml.etree.ElementTree import Element, SubElement, tostring
from dotenv import load_dotenv
from fastapi import FastAPI, Form, HTTPException, Request, status from fastapi import FastAPI, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
load_dotenv()
app = FastAPI() app = FastAPI()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
@@ -22,22 +19,23 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# SMTP / email configuration via environment # SMTP / email configuration via environment
MAIL_ENABLED = os.getenv("MAIL_ENABLED", "false").lower() == "true"
SMTP_HOST = os.getenv("SMTP_HOST", "smtp") SMTP_HOST = os.getenv("SMTP_HOST", "smtp")
SMTP_PORT = int(os.getenv("SMTP_PORT", "25")) SMTP_PORT = int(os.getenv("SMTP_PORT", "25"))
SENDER_MAIL = os.getenv("SENDER_MAIL", "noreply@example.com") MAIL_FROM = os.getenv("MAIL_FROM", "noreply@example.com")
MAIL_TO = os.getenv("MAIL_TO", "destination@example.com") MAIL_TO = os.getenv("MAIL_TO", "destination@example.com")
MAIL_USERNAME = os.getenv("MAIL_USERNAME")
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD")
print(
f"Using SMTP server {SMTP_HOST}:{SMTP_PORT} with user {MAIL_USERNAME}"
f" to send from {SENDER_MAIL} to {MAIL_TO}"
)
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def show_form(request: Request): async def landing_page(request: Request):
return templates.TemplateResponse("form.html", {"request": request}) """Landing page with service selection"""
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/semesterapparat", response_class=HTMLResponse)
async def semesterapparat_form(request: Request):
"""Semesterapparat form page"""
return templates.TemplateResponse("semesterapparat_form.html", {"request": request})
@app.post("/submit") @app.post("/submit")
@@ -45,67 +43,67 @@ async def handle_form(
request: Request, request: Request,
name: str = Form(...), name: str = Form(...),
lastname: str = Form(...), lastname: str = Form(...),
title: Optional[str] = Form(None), title: str = Form(...),
telno: str = Form(...), telno: str = Form(...),
mail: str = Form(...), mail: str = Form(...),
apparatsname: str = Form(...), apparatsname: str = Form(...),
subject: str = Form(...), subject: str = Form(...),
semester_type: str = Form(...), # "summer" or "winter" semester_type: str = Form(...), # "summer" or "winter"
semester_year: str = Form(...), semester_year: str = Form(...),
dauerapparat: bool = Form(False),
authorname: list[str] = Form(...), authorname: list[str] = Form(...),
year: list[str] = Form(...), year: list[str] = Form(...),
booktitle: list[str] = Form(...), booktitle: list[str] = Form(...),
signature: Optional[list[str]] = Form(None), signature: list[str] = Form(...),
message: str = Form(default=""),
): ):
# Build XML
root = Element("form_submission")
static_data = SubElement(root, "static")
SubElement(static_data, "name").text = name
SubElement(static_data, "lastname").text = lastname
# Title is optional
SubElement(static_data, "title").text = title or ""
SubElement(static_data, "telno").text = telno
SubElement(static_data, "mail").text = mail
SubElement(static_data, "apparatsname").text = apparatsname
SubElement(static_data, "subject").text = subject
SubElement(static_data, "semester").text = f"{semester_type} {semester_year}"
SubElement(static_data, "dauerapparat").text = "true" if dauerapparat else "false"
# inside handle_form(), right after parameters are received, before building XML:
# Basic email validation (server-side) # Basic email validation (server-side)
if not EMAIL_REGEX.match(mail): if not EMAIL_REGEX.match(mail):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid email address format.", detail="Invalid email address format.",
) )
# Build XML
root = Element("form_submission")
static_data = SubElement(root, "static")
SubElement(static_data, "name").text = name
SubElement(static_data, "lastname").text = lastname
SubElement(static_data, "title").text = title
SubElement(static_data, "telno").text = telno
SubElement(static_data, "mail").text = mail
SubElement(static_data, "apparatsname").text = apparatsname
SubElement(static_data, "subject").text = subject
SubElement(static_data, "semester").text = f"{semester_type} {semester_year}"
if message:
SubElement(static_data, "message").text = message
books = SubElement(root, "books") books = SubElement(root, "books")
for i in range(len(authorname)): for i in range(len(authorname)):
book = SubElement(books, "book") book = SubElement(books, "book")
SubElement(book, "authorname").text = authorname[i] SubElement(book, "authorname").text = authorname[i]
SubElement(book, "year").text = year[i] SubElement(book, "year").text = year[i]
SubElement(book, "title").text = booktitle[i] SubElement(book, "title").text = booktitle[i]
# Signature is optional; write empty string if not provided SubElement(book, "signature").text = signature[i]
sig_value = ""
if signature and i < len(signature):
sig_raw = signature[i] or ""
sig_value = sig_raw.strip()
SubElement(book, "signature").text = sig_value
xml_data = tostring(root, encoding="unicode") xml_data = tostring(root, encoding="unicode")
# Send mail # Send mail
msg = MIMEText(xml_data, "xml") msg = MIMEText(xml_data, "xml")
msg["Subject"] = "Antrag für neuen Semesterapparat" msg["Subject"] = "New Form Submission"
msg["From"] = SENDER_MAIL msg["From"] = MAIL_FROM
msg["To"] = MAIL_TO msg["To"] = MAIL_TO
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT) as server: if MAIL_ENABLED:
server.connect(SMTP_HOST, SMTP_PORT) with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
# Login only if credentials are provided server.send_message(msg)
if MAIL_USERNAME and MAIL_PASSWORD: else:
server.login(MAIL_USERNAME, MAIL_PASSWORD) print("=" * 80)
print("MAIL SENDING DISABLED - Would have sent:")
print(f"From: {MAIL_FROM}")
print(f"To: {MAIL_TO}")
print(f"Subject: {msg['Subject']}")
print("-" * 80)
print(xml_data)
print("=" * 80)
server.send_message(msg, from_addr=SENDER_MAIL, to_addrs=[MAIL_TO]) return RedirectResponse("/?success=true", status_code=303)
return RedirectResponse("/", status_code=303)

View File

@@ -165,7 +165,8 @@ h2 {
input[type="text"], input[type="text"],
input[type="email"], input[type="email"],
input[type="number"], input[type="number"],
select { select,
textarea {
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -174,10 +175,17 @@ select {
color: var(--text); color: var(--text);
outline: none; outline: none;
transition: border-color .2s ease, box-shadow .2s ease; transition: border-color .2s ease, box-shadow .2s ease;
font-family: inherit;
}
textarea {
resize: vertical;
min-height: 80px;
} }
input:focus, input:focus,
select:focus { select:focus,
textarea:focus {
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 4px var(--ring); box-shadow: 0 0 0 4px var(--ring);
} }
@@ -294,3 +302,299 @@ input[type="radio"] { accent-color: var(--control-accent); }
/* Darken secondary button and add spacing after tables */ /* Darken secondary button and add spacing after tables */
.data-table + .btn { margin-top: 14px; } .data-table + .btn { margin-top: 14px; }
/* Landing page styles */
.landing-hero {
text-align: center;
padding: 40px 0 20px;
}
.landing-hero h1 {
font-size: 2.5rem;
margin-bottom: 12px;
}
.hero-subtitle {
font-size: 1.15rem;
color: var(--muted);
}
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin: 40px 0;
}
.service-card {
position: relative;
background: var(--card-bg);
border: 2px solid var(--border);
border-radius: 16px;
padding: 32px 24px;
text-decoration: none;
color: var(--text);
transition: all 0.3s ease;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.service-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0,0,0,.12);
border-color: var(--primary);
}
.service-card-disabled {
opacity: 0.7;
cursor: not-allowed;
}
.service-card-disabled:hover {
transform: none;
border-color: var(--border);
}
.service-icon {
width: 80px;
height: 80px;
background: var(--primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
font-size: 2.5rem;
color: #fff;
}
.service-card-disabled .service-icon {
background: var(--muted);
}
.service-card h2 {
margin: 0 0 12px;
font-size: 1.5rem;
}
.service-card p {
margin: 0;
color: var(--muted);
line-height: 1.6;
}
.service-arrow {
margin-top: auto;
padding-top: 20px;
font-size: 1.5rem;
color: var(--primary);
}
.service-card-disabled .service-arrow {
display: none;
}
.service-badge {
margin-top: 16px;
padding: 6px 12px;
background: #fef3c7;
color: #92400e;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
}
[data-theme="dark"] .service-badge {
background: #422006;
color: #fde68a;
}
.info-section {
margin: 40px 0;
}
.info-section h3 {
margin-top: 0;
margin-bottom: 16px;
}
.info-links {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 16px;
}
.info-links a {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--primary-700);
text-decoration: none;
font-weight: 500;
}
.info-links a:hover {
text-decoration: underline;
}
/* Modal styles */
.modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
}
.modal-content {
position: relative;
background: var(--card-bg);
border-radius: 16px;
max-width: 500px;
width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.modal-header {
padding: 24px 24px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.warning-icon {
font-size: 2rem;
color: #f59e0b;
}
.modal-body {
padding: 24px;
}
.modal-body p {
margin: 0 0 12px;
line-height: 1.6;
}
.modal-body p:last-child {
margin-bottom: 0;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
}
@media (max-width: 640px) {
.landing-hero h1 {
font-size: 2rem;
}
.service-grid {
grid-template-columns: 1fr;
}
.info-links {
flex-direction: column;
}
}
/* Toast notification styles */
.toast {
position: fixed;
top: 20px;
right: 20px;
background: var(--card-bg);
border: 1px solid #22c55e;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 16px 20px;
display: flex;
align-items: flex-start;
gap: 12px;
max-width: 400px;
z-index: 2000;
opacity: 0;
transform: translateX(450px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast.show {
opacity: 1;
transform: translateX(0);
}
.toast-icon {
font-size: 1.5rem;
color: #22c55e;
flex-shrink: 0;
}
.toast-content {
flex: 1;
}
.toast-title {
font-weight: 600;
margin-bottom: 4px;
color: var(--text);
}
.toast-message {
font-size: 0.9rem;
color: var(--muted);
line-height: 1.4;
}
.toast-close {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 0;
font-size: 1.25rem;
line-height: 1;
flex-shrink: 0;
transition: color 0.2s ease;
}
.toast-close:hover {
color: var(--text);
}
@media (max-width: 640px) {
.toast {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
transform: translateY(-100px);
}
.toast.show {
transform: translateY(0);
}
}

174
app/templates/index.html Normal file
View File

@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html>
<head>
<title>PH Freiburg Bibliothek - Formulare</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/styles.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
</head>
<body>
<header class="site-header">
<div class="container header-inner">
<div class="header-left">
<img class="logo" src="https://www.ph-freiburg.de/_assets/cc3dd7db45300ecc1f3aeed85bda5532/Images/logo-big-blue.svg" alt="PH Freiburg Logo" />
<div class="brand">
<div class="brand-title">Bibliothek der Pädagogischen Hochschule Freiburg</div>
<div class="brand-sub">Hochschulbibliothek · Online-Formulare</div>
</div>
</div>
<button type="button" id="theme-toggle" class="theme-toggle" aria-label="Switch to dark mode" title="Switch to dark mode">
<span class="mdi mdi-theme-light-dark" aria-hidden="true"></span>
</button>
</div>
</header>
<main class="container">
<section class="landing-hero">
<h1>Willkommen</h1>
<p class="hero-subtitle">Bitte wählen Sie den gewünschten Service:</p>
</section>
<div class="service-grid">
<a href="/semesterapparat" class="service-card">
<div class="service-icon">
<span class="mdi mdi-book-open-page-variant"></span>
</div>
<h2>Semesterapparat</h2>
<p>Antrag für die Einrichtung eines Semesterapparats</p>
<div class="service-arrow">
<span class="mdi mdi-arrow-right"></span>
</div>
</a>
<div class="service-card service-card-disabled" onclick="showConstructionWarning()">
<div class="service-icon">
<span class="mdi mdi-library-shelves"></span>
</div>
<h2>ELSA</h2>
<p>Elektronischer Semesterapparat</p>
<div class="service-badge">
<span class="mdi mdi-hammer-wrench"></span> Im Aufbau
</div>
</div>
</div>
<section class="info-section">
<div class="card">
<h3>Hinweise</h3>
<p>
Weitere Informationen zu den Semesterapparaten und elektronischen Angeboten finden Sie auf den Seiten der Hochschulbibliothek.
</p>
<div class="info-links">
<a href="https://www.ph-freiburg.de/bibliothek.html" target="_blank" rel="noopener noreferrer">
<span class="mdi mdi-open-in-new"></span> Zur Bibliothek
</a>
<a href="https://www.ph-freiburg.de/bibliothek/lernen/semesterapparate.html" target="_blank" rel="noopener noreferrer">
<span class="mdi mdi-open-in-new"></span> Zu den Semesterapparaten
</a>
</div>
</div>
</section>
</main>
<div id="construction-modal" class="modal hidden">
<div class="modal-backdrop" onclick="hideConstructionWarning()"></div>
<div class="modal-content">
<div class="modal-header">
<span class="mdi mdi-alert-circle warning-icon"></span>
<h2>Im Aufbau</h2>
</div>
<div class="modal-body">
<p>Das ELSA-Formular befindet sich derzeit noch in der Entwicklung und steht noch nicht zur Verfügung.</p>
<p>Bitte versuchen Sie es zu einem späteren Zeitpunkt erneut.</p>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="hideConstructionWarning()">Verstanden</button>
</div>
</div>
</div>
<div id="success-toast" class="toast hidden">
<div class="toast-icon">
<span class="mdi mdi-check-circle"></span>
</div>
<div class="toast-content">
<div class="toast-title">Erfolgreich gesendet</div>
<div class="toast-message">Mail wurde geschickt, Sie erhalten demnächst mehr Informationen</div>
</div>
<button class="toast-close" onclick="hideSuccessToast()" aria-label="Close">
<span class="mdi mdi-close"></span>
</button>
</div>
<script>
(function() {
const STORAGE_KEY = 'theme';
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const saved = localStorage.getItem(STORAGE_KEY);
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
const btn = document.getElementById('theme-toggle');
if (btn) {
const label = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
btn.setAttribute('aria-label', label);
btn.title = label;
}
}
setTheme(saved || (prefersDark ? 'dark' : 'light'));
const btn = document.getElementById('theme-toggle');
if (btn) {
btn.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
setTheme(current === 'dark' ? 'light' : 'dark');
});
}
})();
function showConstructionWarning() {
document.getElementById('construction-modal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function hideConstructionWarning() {
document.getElementById('construction-modal').classList.add('hidden');
document.body.style.overflow = '';
}
function showSuccessToast() {
const toast = document.getElementById('success-toast');
toast.classList.remove('hidden');
setTimeout(() => {
toast.classList.add('show');
}, 10);
// Auto-hide after 20 seconds
setTimeout(() => {
hideSuccessToast();
}, 20000);
}
function hideSuccessToast() {
const toast = document.getElementById('success-toast');
toast.classList.remove('show');
setTimeout(() => {
toast.classList.add('hidden');
}, 300);
}
// Check for success parameter in URL
(function() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('success') === 'true') {
showSuccessToast();
// Clean URL without reloading
const cleanUrl = window.location.pathname;
window.history.replaceState({}, document.title, cleanUrl);
}
})();
</script>
</body>
</html>

View File

@@ -25,7 +25,7 @@
<main class="container"> <main class="container">
<section class="card"> <section class="card">
<h1>Static Information</h1> <h1>Semesterapparatsinformationen</h1>
<form method="post" action="/submit" class="request-form"> <form method="post" action="/submit" class="request-form">
<!-- Replace table with two-row grid layout --> <!-- Replace table with two-row grid layout -->
<div class="grid-form"> <div class="grid-form">
@@ -113,6 +113,11 @@
</table> </table>
<button type="button" class="btn btn-secondary" onclick="addRow()">+ Medium hinzufügen</button> <button type="button" class="btn btn-secondary" onclick="addRow()">+ Medium hinzufügen</button>
<div class="form-field" style="margin-top: 20px;">
<label for="message">Nachricht (optional)</label>
<textarea name="message" id="message" rows="4" placeholder="Zusätzliche Anmerkungen oder Hinweise..."></textarea>
</div>
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary">Absenden</button> <button type="submit" class="btn btn-primary">Absenden</button>
</div> </div>