Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb59f30b38 | ||
| 455a3b4115 | |||
|
1ad010d079
|
|||
|
85ba0bf07c
|
|||
| c9f03b4f1f | |||
|
36b968998e
|
|||
|
|
2cbd5835a0 | ||
| ef82a4c4ca | |||
|
889890713a
|
|||
| 3a1e58bb8d | |||
|
689bbec348
|
|||
|
b599169868
|
|||
| ae0109050d | |||
|
8ad45cd775
|
|||
|
a7ab44d67b
|
|||
|
cf407ff28f
|
|||
|
84f5c025db
|
|||
|
a2cca9f977
|
|||
| 0ced6ea4e7 | |||
|
51e3e2a39c
|
|||
| c3e9b3c634 | |||
|
6ea0950c66
|
|||
| 9f916356a0 | |||
|
4738732517
|
|||
| 075402803f | |||
| 048feaba33 | |||
| 5e37b57c9b | |||
| ea9c13ae91 | |||
| 64ac7ef0ba | |||
|
658f62f0d0
|
|||
| 6630041119 | |||
|
d7df4033c0
|
|||
| d262f5b34e | |||
|
e227ba9808
|
|||
|
|
ecf1041ce1 | ||
| 1ca8494417 | |||
|
be68e31dc3
|
|||
| 782a3813e8 | |||
|
9f38493b2d
|
@@ -11,3 +11,7 @@ venv/
|
||||
.gitignore
|
||||
node_modules/
|
||||
uv.lock
|
||||
test.py
|
||||
result.xml
|
||||
README.md
|
||||
.gitea/
|
||||
|
||||
61
.gitea/workflows/docker-build.yml
Normal file
61
.gitea/workflows/docker-build.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
name: Docker Build (PR)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !contains(github.event.pull_request.title, '[skip-ci]') }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@master
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
- name: Set up Python
|
||||
run: uv python install
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
- name: Install the project dependencies
|
||||
run: |
|
||||
uv sync --all-groups
|
||||
uv add pip
|
||||
uv export --format requirements.txt -o requirements.txt
|
||||
# uv run python -m pip install --upgrade pip
|
||||
# uv run python -m pip install -r requirements.txt
|
||||
|
||||
- name: Derive lowercase repo name
|
||||
run: |
|
||||
echo "REPO_NAME=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: setup uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
- name: Install Python
|
||||
run: uv python install
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
- name: Install project dependencies
|
||||
run: |
|
||||
uv sync --all-groups
|
||||
uv add pip
|
||||
uv export --format requirements.txt -o requirements.txt
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build image (no push)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: false
|
||||
tags: ${{ secrets.REGISTRY }}/${{ env.REPO_NAME }}:pr-${{ github.event.number }}
|
||||
@@ -2,25 +2,23 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
github_release:
|
||||
description: 'Create Gitea Release'
|
||||
description: "Create Gitea Release"
|
||||
default: true
|
||||
type: boolean
|
||||
docker_release:
|
||||
description: 'Push Docker images'
|
||||
description: "Push Docker images"
|
||||
default: true
|
||||
type: boolean
|
||||
bump:
|
||||
description: 'Bump type'
|
||||
description: "Bump type"
|
||||
required: false
|
||||
default: 'patch'
|
||||
default: "patch"
|
||||
type: choice
|
||||
options:
|
||||
- 'major'
|
||||
- 'minor'
|
||||
- 'patch'
|
||||
- "major"
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -29,8 +27,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history
|
||||
fetch-tags: true # Fetch all tags (refs/tags)
|
||||
fetch-depth: 0 # Fetch full history
|
||||
fetch-tags: true # Fetch all tags (refs/tags)
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
@@ -44,7 +42,7 @@ jobs:
|
||||
uv add pip
|
||||
uv export --format requirements.txt -o requirements.txt
|
||||
# uv run python -m pip install --upgrade pip
|
||||
# uv run python -m pip install -r requirements.txt
|
||||
# uv run python -m pip install -r requirements.txt
|
||||
|
||||
- name: Set Git identity
|
||||
run: |
|
||||
@@ -59,6 +57,7 @@ jobs:
|
||||
echo "VERSION<<EOF" >> $GITHUB_ENV
|
||||
echo "$(uv tool run bump-my-version show current_version)" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -76,33 +75,47 @@ jobs:
|
||||
run: |
|
||||
prev=$(git tag --sort=-v:refname | sed -n '2p' || true)
|
||||
echo "tag=$prev" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Build and store Docker image
|
||||
- name: Compute lowercased image repo
|
||||
if: ${{ github.event.inputs.docker_release == 'true' }}
|
||||
|
||||
run: |
|
||||
REPO_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--tag ${{ secrets.REGISTRY }}/${REPO_NAME}:latest \
|
||||
--tag ${{ secrets.REGISTRY }}/${REPO_NAME}:${{ env.VERSION }} \
|
||||
--push .
|
||||
echo "IMAGE_REPO=${{ secrets.REGISTRY }}/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker meta
|
||||
if: ${{ github.event.inputs.docker_release == 'true' }}
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_REPO }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=raw,value=${{ env.VERSION }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: ${{ github.event.inputs.docker_release == 'true' }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Push changes
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
github_token: ${{ secrets.TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: ${{ github.ref }}
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: https://github.com/mikepenz/release-changelog-builder-action@v5
|
||||
with:
|
||||
platform: "gitea"
|
||||
baseURL: "http://gitea:3000"
|
||||
baseURL: "http://192.168.178.110:3000"
|
||||
configuration: ".gitea/changelog_config.json"
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Create Gitea Release
|
||||
if: ${{ github.event.inputs.github_release == 'true' }}
|
||||
@@ -116,5 +129,3 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -2,14 +2,21 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
# Playwright won't be installed for actual browser automation
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
|
||||
# Install system deps (optional: to build some wheels faster). Kept minimal here.
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency list and install first (leverages Docker layer cache)
|
||||
# Install only runtime dependencies needed for bibapi and requests
|
||||
# This avoids installing Playwright browsers which are huge
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir \
|
||||
--extra-index-url https://git.theprivateserver.de/api/packages/PHB/pypi/simple/ \
|
||||
-r requirements.txt && \
|
||||
# Clean up pip cache
|
||||
rm -rf /root/.cache/pip
|
||||
|
||||
# Copy application code
|
||||
COPY app ./app
|
||||
|
||||
323
app/main.py
323
app/main.py
@@ -4,8 +4,9 @@ import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||
|
||||
from bibapi import catalogue
|
||||
from fastapi import FastAPI, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
@@ -15,10 +16,14 @@ templates = Jinja2Templates(directory="app/templates")
|
||||
# Serve static files (CSS, images)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
# Initialize catalogue for signature validation
|
||||
cat = catalogue.Catalogue()
|
||||
|
||||
# add somewhere near the top-level constants
|
||||
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
# SMTP / email configuration via environment
|
||||
MAIL_ENABLED = os.getenv("MAIL_ENABLED", "false").lower() == "true"
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "smtp")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "25"))
|
||||
MAIL_FROM = os.getenv("MAIL_FROM", "noreply@example.com")
|
||||
@@ -26,8 +31,54 @@ MAIL_TO = os.getenv("MAIL_TO", "destination@example.com")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def show_form(request: Request):
|
||||
return templates.TemplateResponse("form.html", {"request": request})
|
||||
async def landing_page(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.get("/elsa", response_class=HTMLResponse)
|
||||
async def elsa_form(request: Request):
|
||||
"""ELSA form page"""
|
||||
return templates.TemplateResponse("elsa_mono_form.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/api/validate-signature")
|
||||
async def validate_signature(signature: str):
|
||||
"""Validate a book signature and return total pages"""
|
||||
try:
|
||||
book_result = cat.get_book_with_data(signature)
|
||||
if book_result and hasattr(book_result, "pages") and book_result.pages:
|
||||
# Try to extract numeric page count
|
||||
pages_str = str(book_result.pages)
|
||||
# Extract first number from pages string (e.g., "245 S." -> 245)
|
||||
match = re.search(r"(\d+)", pages_str)
|
||||
if match:
|
||||
total_pages = int(match.group(1))
|
||||
return JSONResponse(
|
||||
{"valid": True, "total_pages": total_pages, "signature": signature}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"valid": False,
|
||||
"error": "Signatur nicht gefunden oder keine Seitenzahl verfügbar",
|
||||
"signature": signature,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"valid": False,
|
||||
"error": f"Fehler bei der Validierung: {str(e)}",
|
||||
"signature": signature,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/submit")
|
||||
@@ -46,7 +97,15 @@ async def handle_form(
|
||||
year: list[str] = Form(...),
|
||||
booktitle: list[str] = Form(...),
|
||||
signature: list[str] = Form(...),
|
||||
message: str = Form(default=""),
|
||||
):
|
||||
# Basic email validation (server-side)
|
||||
if not EMAIL_REGEX.match(mail):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid email address format.",
|
||||
)
|
||||
|
||||
# Build XML
|
||||
root = Element("form_submission")
|
||||
|
||||
@@ -59,13 +118,8 @@ async def handle_form(
|
||||
SubElement(static_data, "apparatsname").text = apparatsname
|
||||
SubElement(static_data, "subject").text = subject
|
||||
SubElement(static_data, "semester").text = f"{semester_type} {semester_year}"
|
||||
# inside handle_form(), right after parameters are received, before building XML:
|
||||
# Basic email validation (server-side)
|
||||
if not EMAIL_REGEX.match(mail):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid email address format.",
|
||||
)
|
||||
if message:
|
||||
SubElement(static_data, "message").text = message
|
||||
books = SubElement(root, "books")
|
||||
for i in range(len(authorname)):
|
||||
book = SubElement(books, "book")
|
||||
@@ -82,7 +136,250 @@ async def handle_form(
|
||||
msg["From"] = MAIL_FROM
|
||||
msg["To"] = MAIL_TO
|
||||
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
||||
server.send_message(msg)
|
||||
if MAIL_ENABLED:
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
||||
server.send_message(msg)
|
||||
else:
|
||||
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)
|
||||
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return RedirectResponse("/?success=true", status_code=303)
|
||||
|
||||
|
||||
@app.post("/elsa/submit")
|
||||
async def handle_elsa_form(request: Request):
|
||||
"""Handle ELSA form submission with multiple media types"""
|
||||
form_data = await request.form()
|
||||
|
||||
# Extract general information
|
||||
name = str(form_data.get("name", ""))
|
||||
lastname = str(form_data.get("lastname", ""))
|
||||
title_field = str(form_data.get("title", ""))
|
||||
mail = str(form_data.get("mail", ""))
|
||||
subject = str(form_data.get("subject", ""))
|
||||
classname = str(form_data.get("classname", ""))
|
||||
usage_date_from = str(form_data.get("usage_date_from", ""))
|
||||
usage_date_to = str(form_data.get("usage_date_to", ""))
|
||||
availability_date = str(form_data.get("availability_date", ""))
|
||||
message = str(form_data.get("message", ""))
|
||||
|
||||
# Basic email validation
|
||||
if not EMAIL_REGEX.match(mail):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid email address format.",
|
||||
)
|
||||
|
||||
# Build XML structure
|
||||
root = Element("elsa_submission")
|
||||
|
||||
# General information
|
||||
general_info = SubElement(root, "general_info")
|
||||
SubElement(general_info, "name").text = name
|
||||
SubElement(general_info, "lastname").text = lastname
|
||||
SubElement(general_info, "title").text = title_field
|
||||
SubElement(general_info, "mail").text = mail
|
||||
SubElement(general_info, "subject").text = subject
|
||||
SubElement(general_info, "classname").text = classname
|
||||
SubElement(general_info, "usage_date_from").text = usage_date_from
|
||||
SubElement(general_info, "usage_date_to").text = usage_date_to
|
||||
SubElement(general_info, "availability_date").text = availability_date
|
||||
if message:
|
||||
SubElement(general_info, "message").text = message
|
||||
|
||||
# Process media sections
|
||||
media_root = SubElement(root, "media")
|
||||
|
||||
# Process Monografie entries
|
||||
if "monografie_author[]" in form_data:
|
||||
monografie_authors = [str(v) for v in form_data.getlist("monografie_author[]")]
|
||||
monografie_years = [str(v) for v in form_data.getlist("monografie_year[]")]
|
||||
monografie_editions = [
|
||||
str(v) for v in form_data.getlist("monografie_edition[]")
|
||||
]
|
||||
monografie_titles = [str(v) for v in form_data.getlist("monografie_title[]")]
|
||||
monografie_signatures = [
|
||||
str(v) for v in form_data.getlist("monografie_signature[]")
|
||||
]
|
||||
monografie_pages_from = [
|
||||
str(v) for v in form_data.getlist("monografie_pages_from[]")
|
||||
]
|
||||
monografie_pages_to = [
|
||||
str(v) for v in form_data.getlist("monografie_pages_to[]")
|
||||
]
|
||||
|
||||
# Get section IDs from the form (assuming they're in data-section attributes)
|
||||
# Since we can't directly access data-section from form_data, we'll process sequentially
|
||||
monografie_section = SubElement(media_root, "monografien")
|
||||
for i in range(len(monografie_authors)):
|
||||
entry = SubElement(monografie_section, "entry")
|
||||
SubElement(entry, "author").text = (
|
||||
monografie_authors[i] if i < len(monografie_authors) else ""
|
||||
)
|
||||
SubElement(entry, "year").text = (
|
||||
monografie_years[i] if i < len(monografie_years) else ""
|
||||
)
|
||||
SubElement(entry, "edition").text = (
|
||||
monografie_editions[i] if i < len(monografie_editions) else ""
|
||||
)
|
||||
SubElement(entry, "title").text = (
|
||||
monografie_titles[i] if i < len(monografie_titles) else ""
|
||||
)
|
||||
SubElement(entry, "signature").text = (
|
||||
monografie_signatures[i] if i < len(monografie_signatures) else ""
|
||||
)
|
||||
SubElement(entry, "pages_from").text = (
|
||||
monografie_pages_from[i] if i < len(monografie_pages_from) else ""
|
||||
)
|
||||
SubElement(entry, "pages_to").text = (
|
||||
monografie_pages_to[i] if i < len(monografie_pages_to) else ""
|
||||
)
|
||||
|
||||
# Process Zeitschriftenartikel entries
|
||||
if "zeitschrift_author[]" in form_data:
|
||||
zeitschrift_authors = [
|
||||
str(v) for v in form_data.getlist("zeitschrift_author[]")
|
||||
]
|
||||
zeitschrift_years = [str(v) for v in form_data.getlist("zeitschrift_year[]")]
|
||||
zeitschrift_volumes = [
|
||||
str(v) for v in form_data.getlist("zeitschrift_volume[]")
|
||||
]
|
||||
zeitschrift_article_titles = [
|
||||
str(v) for v in form_data.getlist("zeitschrift_article_title[]")
|
||||
]
|
||||
zeitschrift_journal_titles = [
|
||||
str(v) for v in form_data.getlist("zeitschrift_journal_title[]")
|
||||
]
|
||||
zeitschrift_signatures = [
|
||||
str(v) for v in form_data.getlist("zeitschrift_signature[]")
|
||||
]
|
||||
zeitschrift_pages_from = [
|
||||
str(v) for v in form_data.getlist("zeitschrift_pages_from[]")
|
||||
]
|
||||
zeitschrift_pages_to = [
|
||||
str(v) for v in form_data.getlist("zeitschrift_pages_to[]")
|
||||
]
|
||||
|
||||
zeitschrift_section = SubElement(media_root, "zeitschriftenartikel")
|
||||
for i in range(len(zeitschrift_authors)):
|
||||
entry = SubElement(zeitschrift_section, "entry")
|
||||
SubElement(entry, "author").text = (
|
||||
zeitschrift_authors[i] if i < len(zeitschrift_authors) else ""
|
||||
)
|
||||
SubElement(entry, "year").text = (
|
||||
zeitschrift_years[i] if i < len(zeitschrift_years) else ""
|
||||
)
|
||||
SubElement(entry, "volume").text = (
|
||||
zeitschrift_volumes[i] if i < len(zeitschrift_volumes) else ""
|
||||
)
|
||||
SubElement(entry, "article_title").text = (
|
||||
zeitschrift_article_titles[i]
|
||||
if i < len(zeitschrift_article_titles)
|
||||
else ""
|
||||
)
|
||||
SubElement(entry, "journal_title").text = (
|
||||
zeitschrift_journal_titles[i]
|
||||
if i < len(zeitschrift_journal_titles)
|
||||
else ""
|
||||
)
|
||||
SubElement(entry, "signature").text = (
|
||||
zeitschrift_signatures[i] if i < len(zeitschrift_signatures) else ""
|
||||
)
|
||||
SubElement(entry, "pages_from").text = (
|
||||
zeitschrift_pages_from[i] if i < len(zeitschrift_pages_from) else ""
|
||||
)
|
||||
SubElement(entry, "pages_to").text = (
|
||||
zeitschrift_pages_to[i] if i < len(zeitschrift_pages_to) else ""
|
||||
)
|
||||
|
||||
# Process Herausgeberwerk entries
|
||||
if "herausgeber_publisher[]" in form_data:
|
||||
herausgeber_publishers = [
|
||||
str(v) for v in form_data.getlist("herausgeber_publisher[]")
|
||||
]
|
||||
herausgeber_work_titles = [
|
||||
str(v) for v in form_data.getlist("herausgeber_work_title[]")
|
||||
]
|
||||
herausgeber_years = [str(v) for v in form_data.getlist("herausgeber_year[]")]
|
||||
herausgeber_editions = [
|
||||
str(v) for v in form_data.getlist("herausgeber_edition[]")
|
||||
]
|
||||
herausgeber_article_authors = [
|
||||
str(v) for v in form_data.getlist("herausgeber_article_author[]")
|
||||
]
|
||||
herausgeber_article_titles = [
|
||||
str(v) for v in form_data.getlist("herausgeber_article_title[]")
|
||||
]
|
||||
herausgeber_signatures = [
|
||||
str(v) for v in form_data.getlist("herausgeber_signature[]")
|
||||
]
|
||||
herausgeber_pages_from = [
|
||||
str(v) for v in form_data.getlist("herausgeber_pages_from[]")
|
||||
]
|
||||
herausgeber_pages_to = [
|
||||
str(v) for v in form_data.getlist("herausgeber_pages_to[]")
|
||||
]
|
||||
|
||||
herausgeber_section = SubElement(media_root, "herausgeberwerke")
|
||||
for i in range(len(herausgeber_publishers)):
|
||||
entry = SubElement(herausgeber_section, "entry")
|
||||
SubElement(entry, "publisher").text = (
|
||||
herausgeber_publishers[i] if i < len(herausgeber_publishers) else ""
|
||||
)
|
||||
SubElement(entry, "work_title").text = (
|
||||
herausgeber_work_titles[i] if i < len(herausgeber_work_titles) else ""
|
||||
)
|
||||
SubElement(entry, "year").text = (
|
||||
herausgeber_years[i] if i < len(herausgeber_years) else ""
|
||||
)
|
||||
SubElement(entry, "edition").text = (
|
||||
herausgeber_editions[i] if i < len(herausgeber_editions) else ""
|
||||
)
|
||||
SubElement(entry, "article_author").text = (
|
||||
herausgeber_article_authors[i]
|
||||
if i < len(herausgeber_article_authors)
|
||||
else ""
|
||||
)
|
||||
SubElement(entry, "article_title").text = (
|
||||
herausgeber_article_titles[i]
|
||||
if i < len(herausgeber_article_titles)
|
||||
else ""
|
||||
)
|
||||
SubElement(entry, "signature").text = (
|
||||
herausgeber_signatures[i] if i < len(herausgeber_signatures) else ""
|
||||
)
|
||||
SubElement(entry, "pages_from").text = (
|
||||
herausgeber_pages_from[i] if i < len(herausgeber_pages_from) else ""
|
||||
)
|
||||
SubElement(entry, "pages_to").text = (
|
||||
herausgeber_pages_to[i] if i < len(herausgeber_pages_to) else ""
|
||||
)
|
||||
|
||||
xml_data = tostring(root, encoding="unicode")
|
||||
|
||||
# Send or print email
|
||||
msg = MIMEText(xml_data, "xml")
|
||||
msg["Subject"] = "New ELSA Form Submission"
|
||||
msg["From"] = MAIL_FROM
|
||||
msg["To"] = MAIL_TO
|
||||
|
||||
if MAIL_ENABLED:
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
||||
server.send_message(msg)
|
||||
else:
|
||||
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)
|
||||
|
||||
return RedirectResponse("/?success=true", status_code=303)
|
||||
|
||||
@@ -53,6 +53,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 72px;
|
||||
padding: 14px 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -88,7 +89,7 @@ body {
|
||||
|
||||
/* Layout */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
max-width: 1250px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
@@ -164,7 +165,9 @@ h2 {
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="number"],
|
||||
select {
|
||||
input[type="date"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -173,10 +176,25 @@ select {
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color .2s ease, box-shadow .2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Normalize date input appearance */
|
||||
input[type="date"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
font-family: inherit;
|
||||
/* Ensure text is legible and consistent */
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 4px var(--ring);
|
||||
}
|
||||
@@ -273,6 +291,10 @@ input[type="radio"] { accent-color: var(--control-accent); }
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.inline-controls.start {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.inline-controls input[type="number"] {
|
||||
width: 120px;
|
||||
text-align: center;
|
||||
@@ -289,3 +311,476 @@ input[type="radio"] { accent-color: var(--control-accent); }
|
||||
|
||||
/* Darken secondary button and add spacing after tables */
|
||||
.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 hsl(142, 71%, 45%);
|
||||
background-color: hsl(142, 82%, 30%);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ELSA specific styles */
|
||||
.legal-notice {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .legal-notice {
|
||||
background: #0f2942;
|
||||
border-color: #1e3a52;
|
||||
}
|
||||
|
||||
.legal-notice h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 1rem;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .legal-notice h3 {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.legal-notice p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.media-controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.media-section {
|
||||
background: var(--table-head-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.media-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.media-section-header h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--table-head-bg);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
font-size: 0.9rem;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.media-table {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.media-table th {
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.media-table input[type="text"],
|
||||
.media-table input[type="number"] {
|
||||
font-size: 0.9rem;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.media-table input[type="number"] {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.media-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.media-table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.media-table th,
|
||||
.media-table td {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Signature validation states */
|
||||
.signature-validating {
|
||||
border-color: #3b82f6 !important;
|
||||
background-color: #eff6ff !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .signature-validating {
|
||||
background-color: #1e3a8a !important;
|
||||
}
|
||||
|
||||
.signature-valid {
|
||||
border-color: #22c55e !important;
|
||||
background-color: #f0fdf4 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .signature-valid {
|
||||
background-color: #14532d !important;
|
||||
}
|
||||
|
||||
.signature-invalid {
|
||||
border-color: #ef4444 !important;
|
||||
background-color: #fef2f2 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .signature-invalid {
|
||||
background-color: #7f1d1d !important;
|
||||
}
|
||||
|
||||
/* Threshold exceeded - entire row in red */
|
||||
.threshold-exceeded {
|
||||
background-color: #fee2e2 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .threshold-exceeded {
|
||||
background-color: #991b1b !important;
|
||||
}
|
||||
|
||||
.threshold-exceeded input,
|
||||
.threshold-exceeded select {
|
||||
border-color: #ef4444 !important;
|
||||
}
|
||||
|
||||
/* Disabled submit button styling */
|
||||
button[type="submit"]:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: #9ca3af !important;
|
||||
}
|
||||
|
||||
515
app/templates/elsa_mono_form.html
Normal file
515
app/templates/elsa_mono_form.html
Normal file
@@ -0,0 +1,515 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>ELSA - Elektronischer Semesterapparat</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 · ELSA</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="card">
|
||||
<h1>ELSA - Elektronischer Semesterapparat</h1>
|
||||
|
||||
<div class="legal-notice">
|
||||
<h3><span class="mdi mdi-information"></span> Rechtlicher Hinweis</h3>
|
||||
<p>
|
||||
Das Urheberrecht gestattet gemäß § 60a UrhG zur Veranschaulichung des Unterrichts die Bereitstellung elektronischer Kopien bis max. 15% von veröffentlichten Werken, von einzelnen Aufsätzen aus Fachzeitschriften und von Werken geringen Umfangs (max. 25 S. Gesamtumfang) für einen genau abgegrenzten Benutzerkreis wie den Teilnehmenden einer Lehrveranstaltung, die sich auf ILIAS angemeldet haben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/elsa/submit" class="request-form">
|
||||
<h2>Allgemeine Informationen</h2>
|
||||
<div class="grid-form">
|
||||
<div class="form-field">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" name="name" id="name" required>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="lastname">Nachname</label>
|
||||
<input type="text" name="lastname" id="lastname" required>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="title">Titel (optional)</label>
|
||||
<input type="text" name="title" id="title">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="mail">Email</label>
|
||||
<input type="email" name="mail" id="mail" required>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="subject">Fach</label>
|
||||
<select name="subject" id="subject" required>
|
||||
<option value="">-- Auswählen --</option>
|
||||
<option>Biologie</option>
|
||||
<option>Chemie</option>
|
||||
<option>Deutsch</option>
|
||||
<option>Englisch</option>
|
||||
<option>Erziehungswirtschaft</option>
|
||||
<option>Französisch</option>
|
||||
<option>Geographie</option>
|
||||
<option>Geschichte</option>
|
||||
<option>Gesundheitspädagogik</option>
|
||||
<option>Haushalt / Textil</option>
|
||||
<option>Kunst</option>
|
||||
<option>Mathematik / Informatik</option>
|
||||
<option>Medien in der Bildung</option>
|
||||
<option>Musik</option>
|
||||
<option>Philosophie</option>
|
||||
<option>Physik</option>
|
||||
<option>Politikwissenschaft</option>
|
||||
<option>Prorektorat Lehre und Studium</option>
|
||||
<option>Psychologie</option>
|
||||
<option>Soziologie</option>
|
||||
<option>Sport</option>
|
||||
<option>Technik</option>
|
||||
<option>Theologie</option>
|
||||
<option>Wirtschaftslehre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="classname">Veranstaltungsname</label>
|
||||
<input type="text" name="classname" id="classname" required>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="usage_date_from">Nutzungszeitraum von</label>
|
||||
<input type="date" name="usage_date_from" id="usage_date_from" required>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="usage_date_to">Nutzungszeitraum bis</label>
|
||||
<input type="date" name="usage_date_to" id="usage_date_to" required>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="availability_date">Bereitstellungsdatum</label>
|
||||
<input type="date" name="availability_date" id="availability_date" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Medien</h2>
|
||||
<div class="media-controls">
|
||||
<button type="button" class="btn btn-secondary" onclick="addMediaType('monografie')">
|
||||
<span class="mdi mdi-book"></span> + Monografie
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="addMediaType('zeitschriftenartikel')">
|
||||
<span class="mdi mdi-newspaper"></span> + Zeitschriftenartikel
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="addMediaType('herausgeberwerk')">
|
||||
<span class="mdi mdi-book-multiple"></span> + Herausgeberwerk
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="media-sections"></div>
|
||||
|
||||
<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">
|
||||
<button type="submit" class="btn btn-primary">Absenden</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let mediaCounter = {
|
||||
monografie: 0,
|
||||
zeitschriftenartikel: 0,
|
||||
herausgeberwerk: 0
|
||||
};
|
||||
|
||||
let sectionCounter = 0;
|
||||
|
||||
// Track signature validations: { signature: { totalPages: int, requestedPages: int, inputs: [elements] } }
|
||||
let signatureTracking = {};
|
||||
|
||||
// Debounce timer for signature validation
|
||||
let validationTimers = {};
|
||||
|
||||
// Theme toggle functionality (in IIFE to avoid polluting global scope)
|
||||
(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');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// Media management functions (global scope for onclick handlers)
|
||||
function addMediaType(type) {
|
||||
const container = document.getElementById('media-sections');
|
||||
const sectionId = 'section-' + sectionCounter++;
|
||||
|
||||
const section = document.createElement('div');
|
||||
section.className = 'media-section';
|
||||
section.id = sectionId;
|
||||
section.setAttribute('data-type', type);
|
||||
|
||||
let title = '';
|
||||
let tableHtml = '';
|
||||
|
||||
if (type === 'monografie') {
|
||||
title = 'Monografie';
|
||||
tableHtml = '<table class="data-table media-table" id="table-' + sectionId + '">' +
|
||||
'<tr>' +
|
||||
'<th>Autor<br>(Nachname, Vorname)</th>' +
|
||||
'<th>Jahr</th>' +
|
||||
'<th>Auflage</th>' +
|
||||
'<th>Titel</th>' +
|
||||
'<th>Signatur</th>' +
|
||||
'<th>Seiten von</th>' +
|
||||
'<th>Seiten bis</th>' +
|
||||
'<th></th>' +
|
||||
'</tr>' +
|
||||
'</table>';
|
||||
} else if (type === 'zeitschriftenartikel') {
|
||||
title = 'Zeitschriftenartikel';
|
||||
tableHtml = '<table class="data-table media-table" id="table-' + sectionId + '">' +
|
||||
'<tr>' +
|
||||
'<th>Autor<br>(Nachname, Vorname)</th>' +
|
||||
'<th>Jahr</th>' +
|
||||
'<th>Band</th>' +
|
||||
'<th>Titel des Artikels</th>' +
|
||||
'<th>Titel der Zeitschrift</th>' +
|
||||
'<th>Signatur</th>' +
|
||||
'<th>Seiten von</th>' +
|
||||
'<th>Seiten bis</th>' +
|
||||
'<th></th>' +
|
||||
'</tr>' +
|
||||
'</table>';
|
||||
} else if (type === 'herausgeberwerk') {
|
||||
title = 'Herausgeberwerk';
|
||||
tableHtml = '<table class="data-table media-table" id="table-' + sectionId + '">' +
|
||||
'<tr>' +
|
||||
'<th>Herausgeber<br>(Nachname, Vorname)</th>' +
|
||||
'<th>Titel des Werks</th>' +
|
||||
'<th>Jahr</th>' +
|
||||
'<th>Auflage</th>' +
|
||||
'<th>Autor des Artikels<br>(Nachname, Vorname)</th>' +
|
||||
'<th>Titel des Artikels</th>' +
|
||||
'<th>Signatur</th>' +
|
||||
'<th>Seiten von</th>' +
|
||||
'<th>Seiten bis</th>' +
|
||||
'<th></th>' +
|
||||
'</tr>' +
|
||||
'</table>';
|
||||
}
|
||||
|
||||
section.innerHTML = '<div class="media-section-header">' +
|
||||
'<h3><span class="mdi ' + getIconForType(type) + '"></span> ' + title + '</h3>' +
|
||||
'<button type="button" class="btn-icon" onclick="removeMediaSection(\'' + sectionId + '\')" title="Sektion entfernen">' +
|
||||
'<span class="mdi mdi-close"></span>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
tableHtml +
|
||||
'<button type="button" class="btn btn-secondary btn-sm" onclick="addMediaRow(\'' + sectionId + '\', \'' + type + '\')">' +
|
||||
'+ Eintrag hinzufügen' +
|
||||
'</button>';
|
||||
|
||||
container.appendChild(section);
|
||||
|
||||
// Add first row automatically
|
||||
addMediaRow(sectionId, type);
|
||||
}
|
||||
|
||||
function getIconForType(type) {
|
||||
const icons = {
|
||||
'monografie': 'mdi-book',
|
||||
'zeitschriftenartikel': 'mdi-newspaper',
|
||||
'herausgeberwerk': 'mdi-book-multiple'
|
||||
};
|
||||
return icons[type] || 'mdi-file';
|
||||
}
|
||||
|
||||
function addMediaRow(sectionId, type) {
|
||||
const table = document.getElementById('table-' + sectionId);
|
||||
const row = table.insertRow(-1);
|
||||
const rowId = sectionId + '-row-' + mediaCounter[type]++;
|
||||
row.id = rowId;
|
||||
|
||||
if (type === 'monografie') {
|
||||
row.innerHTML = '<td><input type="text" name="monografie_author[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="monografie_year[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="monografie_edition[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="monografie_title[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="monografie_signature[]" data-section="' + sectionId + '" placeholder="Optional"></td>' +
|
||||
'<td><input type="number" name="monografie_pages_from[]" data-section="' + sectionId + '" required min="1"></td>' +
|
||||
'<td><input type="number" name="monografie_pages_to[]" data-section="' + sectionId + '" required min="1"></td>' +
|
||||
'<td><button type="button" class="btn-icon" onclick="removeRow(\'' + rowId + '\')" title="Zeile entfernen"><span class="mdi mdi-delete"></span></button></td>';
|
||||
attachSignatureListeners(row);
|
||||
} else if (type === 'zeitschriftenartikel') {
|
||||
row.innerHTML = '<td><input type="text" name="zeitschrift_author[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="zeitschrift_year[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="zeitschrift_volume[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="zeitschrift_article_title[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="zeitschrift_journal_title[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="zeitschrift_signature[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="number" name="zeitschrift_pages_from[]" data-section="' + sectionId + '" required min="1"></td>' +
|
||||
'<td><input type="number" name="zeitschrift_pages_to[]" data-section="' + sectionId + '" required min="1"></td>' +
|
||||
'<td><button type="button" class="btn-icon" onclick="removeRow(\'' + rowId + '\')" title="Zeile entfernen"><span class="mdi mdi-delete"></span></button></td>';
|
||||
} else if (type === 'herausgeberwerk') {
|
||||
row.innerHTML = '<td><input type="text" name="herausgeber_publisher[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="herausgeber_work_title[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="herausgeber_year[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="herausgeber_edition[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="herausgeber_article_author[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="herausgeber_article_title[]" data-section="' + sectionId + '" required></td>' +
|
||||
'<td><input type="text" name="herausgeber_signature[]" data-section="' + sectionId + '" placeholder="Optional"></td>' +
|
||||
'<td><input type="number" name="herausgeber_pages_from[]" data-section="' + sectionId + '" required min="1"></td>' +
|
||||
'<td><input type="number" name="herausgeber_pages_to[]" data-section="' + sectionId + '" required min="1"></td>' +
|
||||
'<td><button type="button" class="btn-icon" onclick="removeRow(\'' + rowId + '\')" title="Zeile entfernen"><span class="mdi mdi-delete"></span></button></td>';
|
||||
attachSignatureListeners(row);
|
||||
}
|
||||
}
|
||||
|
||||
function removeRow(rowId) {
|
||||
const row = document.getElementById(rowId);
|
||||
if (row) {
|
||||
const signatureInput = row.querySelector('input[name*="_signature"]');
|
||||
if (signatureInput) {
|
||||
cleanupSignatureTracking(signatureInput);
|
||||
}
|
||||
row.remove();
|
||||
updateSubmitButton();
|
||||
}
|
||||
}
|
||||
|
||||
function removeMediaSection(sectionId) {
|
||||
const section = document.getElementById(sectionId);
|
||||
if (section) {
|
||||
if (confirm('Möchten Sie diese Sektion wirklich entfernen?')) {
|
||||
// Clean up tracking for removed rows
|
||||
const rows = section.querySelectorAll('tr[id]');
|
||||
rows.forEach(row => {
|
||||
const signatureInput = row.querySelector('input[name*="_signature"]');
|
||||
if (signatureInput) {
|
||||
cleanupSignatureTracking(signatureInput);
|
||||
}
|
||||
});
|
||||
section.remove();
|
||||
updateSubmitButton();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSignature(signatureInput, pagesFromInput, pagesToInput) {
|
||||
const signature = signatureInput.value.trim();
|
||||
|
||||
if (!signature) {
|
||||
// Clear validation state if signature is empty
|
||||
signatureInput.classList.remove('signature-validating', 'signature-valid', 'signature-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing timer for this input
|
||||
const inputId = signatureInput.id || signatureInput.name;
|
||||
if (validationTimers[inputId]) {
|
||||
clearTimeout(validationTimers[inputId]);
|
||||
}
|
||||
|
||||
// Show validating state
|
||||
signatureInput.classList.add('signature-validating');
|
||||
signatureInput.classList.remove('signature-valid', 'signature-invalid');
|
||||
|
||||
// Debounce the API call
|
||||
validationTimers[inputId] = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/validate-signature?signature=' + encodeURIComponent(signature));
|
||||
const data = await response.json();
|
||||
|
||||
if (data.valid) {
|
||||
// Initialize tracking for this signature if needed
|
||||
if (!signatureTracking[signature]) {
|
||||
signatureTracking[signature] = {
|
||||
totalPages: data.total_pages,
|
||||
inputs: []
|
||||
};
|
||||
} else {
|
||||
signatureTracking[signature].totalPages = data.total_pages;
|
||||
}
|
||||
|
||||
// Track this input
|
||||
const existingIndex = signatureTracking[signature].inputs.findIndex(
|
||||
item => item.signature === signatureInput
|
||||
);
|
||||
if (existingIndex === -1) {
|
||||
signatureTracking[signature].inputs.push({
|
||||
signature: signatureInput,
|
||||
pagesFrom: pagesFromInput,
|
||||
pagesTo: pagesToInput
|
||||
});
|
||||
}
|
||||
|
||||
signatureInput.classList.remove('signature-validating');
|
||||
signatureInput.classList.add('signature-valid');
|
||||
signatureInput.title = 'Signatur gefunden: ' + data.total_pages + ' Seiten';
|
||||
|
||||
// Recalculate all pages for this signature
|
||||
checkSignatureThreshold(signature);
|
||||
} else {
|
||||
signatureInput.classList.remove('signature-validating');
|
||||
signatureInput.classList.add('signature-invalid');
|
||||
signatureInput.title = data.error || 'Signatur nicht gefunden';
|
||||
updateSubmitButton();
|
||||
}
|
||||
} catch (error) {
|
||||
signatureInput.classList.remove('signature-validating');
|
||||
signatureInput.classList.add('signature-invalid');
|
||||
signatureInput.title = 'Validierungsfehler';
|
||||
updateSubmitButton();
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function checkSignatureThreshold(signature) {
|
||||
const tracking = signatureTracking[signature];
|
||||
if (!tracking) return;
|
||||
|
||||
let totalRequestedPages = 0;
|
||||
const threshold = Math.ceil(tracking.totalPages * 0.15);
|
||||
|
||||
// Calculate total requested pages across all inputs for this signature
|
||||
tracking.inputs.forEach(item => {
|
||||
const from = parseInt(item.pagesFrom.value) || 0;
|
||||
const to = parseInt(item.pagesTo.value) || 0;
|
||||
if (from > 0 && to > 0 && to >= from) {
|
||||
totalRequestedPages += (to - from + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const isOverThreshold = totalRequestedPages > threshold;
|
||||
|
||||
// Update all rows with this signature
|
||||
tracking.inputs.forEach(item => {
|
||||
const row = item.signature.closest('tr');
|
||||
if (row) {
|
||||
if (isOverThreshold) {
|
||||
row.classList.add('threshold-exceeded');
|
||||
item.signature.title = 'Warnung: Gesamtanzahl der Seiten (' + totalRequestedPages + ') überschreitet 15% (' + threshold + ' Seiten)';
|
||||
} else {
|
||||
row.classList.remove('threshold-exceeded');
|
||||
item.signature.title = 'Signatur gefunden: ' + tracking.totalPages + ' Seiten (Aktuell: ' + totalRequestedPages + '/' + threshold + ')';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
function cleanupSignatureTracking(signatureInput) {
|
||||
const signature = signatureInput.value.trim();
|
||||
if (signature && signatureTracking[signature]) {
|
||||
signatureTracking[signature].inputs = signatureTracking[signature].inputs.filter(
|
||||
item => item.signature !== signatureInput
|
||||
);
|
||||
|
||||
// Remove signature from tracking if no more inputs
|
||||
if (signatureTracking[signature].inputs.length === 0) {
|
||||
delete signatureTracking[signature];
|
||||
} else {
|
||||
// Recalculate for remaining inputs
|
||||
checkSignatureThreshold(signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateSubmitButton() {
|
||||
const submitButton = document.querySelector('button[type="submit"]');
|
||||
const hasInvalidSignatures = document.querySelectorAll('.signature-invalid').length > 0;
|
||||
const hasThresholdExceeded = document.querySelectorAll('.threshold-exceeded').length > 0;
|
||||
|
||||
if (hasInvalidSignatures || hasThresholdExceeded) {
|
||||
submitButton.disabled = true;
|
||||
if (hasThresholdExceeded) {
|
||||
submitButton.title = 'Formular kann nicht abgesendet werden: 15%-Grenze überschritten';
|
||||
} else {
|
||||
submitButton.title = 'Formular kann nicht abgesendet werden: Ungültige Signaturen';
|
||||
}
|
||||
} else {
|
||||
submitButton.disabled = false;
|
||||
submitButton.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
function attachSignatureListeners(row) {
|
||||
const signatureInput = row.querySelector('input[name*="_signature"]');
|
||||
const pagesFromInput = row.querySelector('input[name*="_pages_from"]');
|
||||
const pagesToInput = row.querySelector('input[name*="_pages_to"]');
|
||||
|
||||
if (signatureInput && pagesFromInput && pagesToInput) {
|
||||
// Generate unique ID if not present
|
||||
if (!signatureInput.id) {
|
||||
signatureInput.id = 'sig-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
// Validate on signature change
|
||||
signatureInput.addEventListener('input', () => {
|
||||
const oldSignature = signatureInput.dataset.lastSignature || '';
|
||||
const newSignature = signatureInput.value.trim();
|
||||
|
||||
// Cleanup old signature tracking if it changed
|
||||
if (oldSignature && oldSignature !== newSignature) {
|
||||
const tempInput = document.createElement('input');
|
||||
tempInput.value = oldSignature;
|
||||
cleanupSignatureTracking(tempInput);
|
||||
}
|
||||
|
||||
signatureInput.dataset.lastSignature = newSignature;
|
||||
validateSignature(signatureInput, pagesFromInput, pagesToInput);
|
||||
});
|
||||
|
||||
// Recalculate when page range changes
|
||||
pagesFromInput.addEventListener('input', () => {
|
||||
const signature = signatureInput.value.trim();
|
||||
if (signature && signatureTracking[signature]) {
|
||||
checkSignatureThreshold(signature);
|
||||
}
|
||||
});
|
||||
|
||||
pagesToInput.addEventListener('input', () => {
|
||||
const signature = signatureInput.value.trim();
|
||||
if (signature && signatureTracking[signature]) {
|
||||
checkSignatureThreshold(signature);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
174
app/templates/index.html
Normal file
174
app/templates/index.html
Normal 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>
|
||||
|
||||
<a href="/elsa" class="service-card">
|
||||
<div class="service-icon">
|
||||
<span class="mdi mdi-library-shelves"></span>
|
||||
</div>
|
||||
<h2>ELSA</h2>
|
||||
<p>Elektronischer Semesterapparat</p>
|
||||
<div class="service-arrow">
|
||||
<span class="mdi mdi-arrow-right"></span>
|
||||
</div>
|
||||
</a>
|
||||
</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>
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<main class="container">
|
||||
<section class="card">
|
||||
<h1>Static Information</h1>
|
||||
<h1>Semesterapparatsinformationen</h1>
|
||||
<form method="post" action="/submit" class="request-form">
|
||||
<!-- Replace table with two-row grid layout -->
|
||||
<div class="grid-form">
|
||||
@@ -38,8 +38,8 @@
|
||||
<input type="text" name="lastname" id="lastname" required>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="title">Titel</label>
|
||||
<input type="text" name="title" id="title" required>
|
||||
<label for="title">Titel (optional)</label>
|
||||
<input type="text" name="title" id="title">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="telno">Telefonnummer</label>
|
||||
@@ -89,27 +89,35 @@
|
||||
<div class="form-field">
|
||||
<label>Semester</label>
|
||||
<div class="inline-controls">
|
||||
<label class="radio"><input type="radio" name="semester_type" value="summer" required>Sommer</label>
|
||||
<label class="radio"><input type="radio" name="semester_type" value="winter" required>Winter</label>
|
||||
<label class="radio"><input type="radio" name="semester_type" value="SoSe" required>Sommer</label>
|
||||
<label class="radio"><input type="radio" name="semester_type" value="WiSe" required>Winter</label>
|
||||
<input type="number" name="semester_year" placeholder="Jahr" required>
|
||||
</div>
|
||||
<div class="inline-controls start" style="margin-top: 0.5rem;">
|
||||
<label class="checkbox"><input type="checkbox" name="dauerapparat" value="true"> Dauerapparat</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Medien</h2>
|
||||
<table id="book-table" class="data-table">
|
||||
<tr>
|
||||
<th>Autorenname</th><th>Jahr/Auflage</th><th>Titel</th><th>Signatur</th>
|
||||
<th>Autorenname</th><th>Jahr/Auflage</th><th>Titel</th><th>Signatur (wenn vorhanden)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="text" name="authorname" required></td>
|
||||
<td><input type="text" name="year" required></td>
|
||||
<td><input type="text" name="booktitle" required></td>
|
||||
<td><input type="text" name="signature" required></td>
|
||||
<td><input type="text" name="signature"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<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">
|
||||
<button type="submit" class="btn btn-primary">Absenden</button>
|
||||
</div>
|
||||
@@ -136,7 +144,7 @@
|
||||
<td><input type="text" name="authorname" required></td>
|
||||
<td><input type="text" name="year" required></td>
|
||||
<td><input type="text" name="booktitle" required></td>
|
||||
<td><input type="text" name="signature" required></td>
|
||||
<td><input type="text" name="signature"></td>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ services:
|
||||
build: .
|
||||
container_name: semapform
|
||||
environment:
|
||||
- MAIL_ENABLED=${MAIL_ENABLED:-false}
|
||||
- SMTP_HOST=${SMTP_HOST:-smtp}
|
||||
- SMTP_PORT=${SMTP_PORT:-25}
|
||||
- MAIL_FROM=${MAIL_FROM:-noreply@example.com}
|
||||
|
||||
5
main.py
5
main.py
@@ -1,6 +1,5 @@
|
||||
from app.main import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=5001)
|
||||
# Run the FastAPI app defined in app/main.py as variable `app`
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=5001, reload=False)
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
[project]
|
||||
name = "semapform"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"bibapi>=0.0.4",
|
||||
"fastapi>=0.117.1",
|
||||
"jinja2>=3.1.6",
|
||||
"pip>=25.2",
|
||||
"python-dotenv>=1.1.1",
|
||||
"python-multipart>=0.0.20",
|
||||
"uvicorn>=0.37.0",
|
||||
]
|
||||
|
||||
[tool.bumpversion]
|
||||
current_version = "0.1.1"
|
||||
current_version = "0.2.1"
|
||||
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
||||
serialize = ["{major}.{minor}.{patch}"]
|
||||
search = "{current_version}"
|
||||
@@ -33,3 +35,8 @@ commit_args = ""
|
||||
setup_hooks = []
|
||||
pre_commit_hooks = []
|
||||
post_commit_hooks = []
|
||||
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "gitea"
|
||||
url = "https://git.theprivateserver.de/api/packages/PHB/pypi/simple/"
|
||||
|
||||
24
uv.lock
generated
24
uv.lock
generated
@@ -116,6 +116,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pip"
|
||||
version = "25.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/16/650289cd3f43d5a2fadfd98c68bd1e1e7f2550a1a5326768cddfbcedb2c5/pip-25.2.tar.gz", hash = "sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2", size = 1840021 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/3f/945ef7ab14dc4f9d7f40288d2df998d1837ee0888ec3659c813487572faa/pip-25.2-py3-none-any.whl", hash = "sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717", size = 1752557 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.9"
|
||||
@@ -159,6 +168,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.20"
|
||||
@@ -170,11 +188,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "semapform"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "pip" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
@@ -183,6 +203,8 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.117.1" },
|
||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||
{ name = "pip", specifier = ">=25.2" },
|
||||
{ name = "python-dotenv", specifier = ">=1.1.1" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "uvicorn", specifier = ">=0.37.0" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user