32 Commits
v0.1.1 ... dev

Author SHA1 Message Date
f76a8d2efc fix: always compute repo name
All checks were successful
Docker Build (PR) / Build Docker image (pull_request) Has been skipped
/ build (pull_request) Successful in 25m38s
2025-11-19 20:11:04 +01:00
51f5aefcaa fix: add no cache option
Some checks failed
Docker Build (PR) / Build Docker image (pull_request) Has been skipped
/ build (pull_request) Has been cancelled
2025-11-19 19:39:31 +01:00
5c4a2a8ffc feat: update release workflow to auto create release based on PR
All checks were successful
Docker Build (PR) / Build Docker image (pull_request) Has been skipped
/ build (pull_request) Has been skipped
2025-11-19 19:24:55 +01:00
0f934a6671 chore: switch colors
All checks were successful
Docker Build (PR) / Build Docker image (pull_request) Successful in 4m55s
2025-11-19 18:57:22 +01:00
d697c3e268 fix: allow only one instance of mediatype to be present 2025-11-19 18:57:09 +01:00
1ad010d079 feat: add background color to toast
All checks were successful
Docker Build (PR) / Build Docker image (pull_request) Successful in 5m7s
2025-11-19 18:30:36 +01:00
85ba0bf07c chore:
update csss: increase border by 50px
chore: add line break to names
chore: restyle the dateselector to use same design as other fields
2025-11-19 18:27:17 +01:00
36b968998e fix typo [skip-ci]
All checks were successful
Docker Build (PR) / Build Docker image (pull_request) Has been skipped
2025-11-19 17:49:20 +01:00
889890713a update example, add mail_enabled env
All checks were successful
Docker Build (PR) / Build Docker image (pull_request) Has been skipped
2025-11-19 16:42:16 +01:00
689bbec348 add check to skip PRs with skip text
All checks were successful
Docker Build (PR) / Build Docker image (pull_request) Successful in 2m21s
2025-11-19 16:40:59 +01:00
b599169868 chore: fix typo 2025-11-19 16:09:22 +01:00
8ad45cd775 test: add gitea pypi index url
All checks were successful
Docker Build (PR) / Build Docker image (pull_request) Successful in 2m3s
2025-11-19 16:03:02 +01:00
a7ab44d67b feat: change docker image creation
Some checks failed
Docker Build (PR) / Build Docker image (pull_request) Failing after 2m0s
2025-11-19 15:56:36 +01:00
cf407ff28f chore: remove arm build
Some checks failed
Docker Build (PR) / Build Docker image (pull_request) Failing after 4m11s
2025-11-19 15:55:17 +01:00
84f5c025db chore: update release workflow to use new format and setup for docker
Some checks failed
Docker Build (PR) / Build Docker image (pull_request) Failing after 5m3s
2025-11-19 15:48:09 +01:00
a2cca9f977 feat: calculate 15% limit for book(s), blocking submission if over the limit 2025-11-19 15:12:39 +01:00
51e3e2a39c feat: add elsa form with a modular addition of content type
All checks were successful
Docker Build (PR) / Build Docker image (pull_request) Successful in 4m49s
2025-11-19 14:10:22 +01:00
6ea0950c66 chore: add uv and python setup to test to generate requirements file
All checks were successful
Docker Build (PR) / Build Docker image (pull_request) Successful in 2m11s
2025-11-19 14:06:20 +01:00
4738732517 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
2025-11-19 13:22:30 +01:00
5e37b57c9b Feat: update form, allow some fields to be optional; add dauerapp check; get mail setup working using env variables
Some checks failed
Docker Build (PR) / Build Docker image (pull_request) Has been cancelled
2025-10-10 09:53:49 +02:00
ea9c13ae91 add a test build for PRs 2025-10-10 09:51:11 +02:00
64ac7ef0ba Merge pull request 'new attempt for arm builds' (#9) from dev into main
Reviewed-on: #9
2025-09-28 13:49:57 +01:00
658f62f0d0 new attempt for arm builds 2025-09-28 14:47:56 +02:00
6630041119 Merge pull request 'chore: switch python base image to try arm builds' (#8) from dev into main
Reviewed-on: #8
2025-09-28 11:43:50 +01:00
d7df4033c0 chore: switch python base image to try arm builds 2025-09-28 12:43:21 +02:00
d262f5b34e Merge pull request 'feat: add support for arm/v7 architecture in Docker build' (#7) from dev into main
Reviewed-on: #7
2025-09-28 11:39:38 +01:00
e227ba9808 feat: add support for arm/v7 architecture in Docker build 2025-09-28 12:39:16 +02:00
Gitea CI
ecf1041ce1 Bump version: 0.1.1 → 0.1.2 2025-09-28 09:43:16 +00:00
1ca8494417 Merge pull request 'fix typo' (#6) from dev into main
Reviewed-on: #6
2025-09-28 10:42:48 +01:00
be68e31dc3 fix typo 2025-09-28 11:42:20 +02:00
782a3813e8 Merge pull request 'feat: update workflow to allow arm builds' (#5) from dev into main
Reviewed-on: #5
2025-09-28 10:40:59 +01:00
9f38493b2d feat: update workflow to allow arm builds 2025-09-28 11:40:34 +02:00
14 changed files with 1701 additions and 63 deletions

View File

@@ -11,3 +11,7 @@ venv/
.gitignore
node_modules/
uv.lock
test.py
result.xml
README.md
.gitea/

View File

@@ -0,0 +1,48 @@
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: 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 }}

View File

@@ -2,35 +2,60 @@ 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"
pull_request:
types: [closed]
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
# Only run on merged PRs with [release-*] or manual workflow_dispatch
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.title, '[release-'))
steps:
- name: Determine bump type
id: bump_type
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "type=${{ github.event.inputs.bump }}" >> $GITHUB_OUTPUT
else
# Extract bump type from PR title: [release-patch], [release-minor], [release-major]
PR_TITLE="${{ github.event.pull_request.title }}"
if [[ "$PR_TITLE" =~ \[release-major\] ]]; then
echo "type=major" >> $GITHUB_OUTPUT
elif [[ "$PR_TITLE" =~ \[release-minor\] ]]; then
echo "type=minor" >> $GITHUB_OUTPUT
elif [[ "$PR_TITLE" =~ \[release-patch\] ]]; then
echo "type=patch" >> $GITHUB_OUTPUT
else
echo "type=patch" >> $GITHUB_OUTPUT
fi
fi
- 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 +69,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: |
@@ -54,11 +79,12 @@ jobs:
id: bump
run: |
uv tool install bump-my-version
uv tool run bump-my-version bump ${{ github.event.inputs.bump }}
uv tool run bump-my-version bump ${{ steps.bump_type.outputs.type }}
# echo the version to github env, the version is shown by using uv tool run bump-my-version show current_version
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,36 +102,51 @@ jobs:
run: |
prev=$(git tag --sort=-v:refname | sed -n '2p' || true)
echo "tag=$prev" >> "$GITHUB_OUTPUT"
- name: Build and store Docker image
if: ${{ github.event.inputs.docker_release == 'true' }}
- name: Compute lowercased image repo
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.docker_release == 'true' || github.event_name == 'pull_request' }}
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_name == 'workflow_dispatch' && github.event.inputs.docker_release == 'true' || github.event_name == 'pull_request' }}
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_name == 'workflow_dispatch' && github.event.inputs.docker_release == 'true' || github.event_name == 'pull_request' }}
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
no-cache: 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' }}
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.github_release == 'true' || github.event_name == 'pull_request' }}
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ env.VERSION }}
@@ -116,5 +157,3 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}

View File

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

View File

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

View File

@@ -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);
}
@@ -222,6 +240,13 @@ input[type="radio"] { accent-color: var(--control-accent); }
}
[data-theme="dark"] .btn-secondary:hover { background: #1f2b3f; }
/* Generic disabled button state */
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
filter: grayscale(0.15);
}
.actions {
margin-top: 16px;
display: flex;
@@ -273,6 +298,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 +318,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, 82%, 30%);
background-color: hsl(142, 71%, 45%);
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;
}

View File

@@ -0,0 +1,530 @@
<!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" id="btn-monografie" class="btn btn-secondary" onclick="addMediaType('monografie')" title="Monografie Sektion hinzufügen">
<span class="mdi mdi-book"></span> + Monografie
</button>
<button type="button" id="btn-zeitschriftenartikel" class="btn btn-secondary" onclick="addMediaType('zeitschriftenartikel')" title="Zeitschriftenartikel Sektion hinzufügen">
<span class="mdi mdi-newspaper"></span> + Zeitschriftenartikel
</button>
<button type="button" id="btn-herausgeberwerk" class="btn btn-secondary" onclick="addMediaType('herausgeberwerk')" title="Herausgeberwerk Sektion hinzufügen">
<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 btn = document.getElementById('btn-' + type);
// Prevent duplicate sections of same type
if (btn && btn.disabled) { return; }
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);
// Disable button for this type until section removed
if (btn) {
btn.disabled = true;
btn.title = 'Sektion bereits hinzugefügt entfernen zum erneuten Hinzufügen';
}
// 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?')) {
const type = section.getAttribute('data-type');
// 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();
// Re-enable button for this media type
const btn = document.getElementById('btn-' + type);
if (btn) {
btn.disabled = false;
btn.title = 'Sektion hinzufügen';
}
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
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>
<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>

View File

@@ -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>
`;
}

View File

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

View File

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

View File

@@ -1,19 +1,21 @@
[project]
name = "semapform"
version = "0.1.1"
version = "0.1.2"
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.1.2"
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
View File

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