From a2cca9f9777700f5f34dd58f5327735ad8ecc183 Mon Sep 17 00:00:00 2001 From: WorldTeacher Date: Wed, 19 Nov 2025 15:12:39 +0100 Subject: [PATCH 1/5] feat: calculate 15% limit for book(s), blocking submission if over the limit --- app/main.py | 39 +++++- app/static/styles.css | 49 +++++++ app/templates/elsa_mono_form.html | 205 ++++++++++++++++++++++++++++++ pyproject.toml | 6 + 4 files changed, 298 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 2ea2be1..dacc0e2 100755 --- a/app/main.py +++ b/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,6 +16,9 @@ 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]+$") @@ -44,6 +48,39 @@ async def elsa_form(request: Request): 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") async def handle_form( request: Request, diff --git a/app/static/styles.css b/app/static/styles.css index 33b94b8..bb489ae 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -725,3 +725,52 @@ input[type="radio"] { accent-color: var(--control-accent); } 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; +} diff --git a/app/templates/elsa_mono_form.html b/app/templates/elsa_mono_form.html index 1f1a081..7441c93 100644 --- a/app/templates/elsa_mono_form.html +++ b/app/templates/elsa_mono_form.html @@ -136,6 +136,12 @@ }; 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() { @@ -266,6 +272,7 @@ '' + '' + ''; + attachSignatureListeners(row); } else if (type === 'zeitschriftenartikel') { row.innerHTML = '' + '' + @@ -287,13 +294,19 @@ '' + '' + ''; + 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(); } } @@ -301,10 +314,202 @@ 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); + } + }); + } + } diff --git a/pyproject.toml b/pyproject.toml index 1c4ae5f..88e9341 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ 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", @@ -34,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/" -- 2.49.1 From 84f5c025db26cf6b9c3346a60ea066b8418401c2 Mon Sep 17 00:00:00 2001 From: WorldTeacher Date: Wed, 19 Nov 2025 15:48:09 +0100 Subject: [PATCH 2/5] chore: update release workflow to use new format and setup for docker --- .gitea/workflows/release.yml | 67 +++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4eac1e4..1f9c846 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,35 +2,33 @@ 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 steps: - name: Checkout code - uses: actions/checkout@master + 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: | @@ -61,6 +59,8 @@ jobs: echo "EOF" >> $GITHUB_ENV - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -78,31 +78,46 @@ jobs: echo "tag=$prev" >> "$GITHUB_OUTPUT" - - name: Build and store Docker image for multiple architectures + - 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,linux/arm64,linux/arm/v7 \ - --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,linux/arm64 + 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' }} @@ -115,6 +130,4 @@ jobs: prerelease: false env: GITHUB_TOKEN: ${{ secrets.TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - - + GITHUB_REPOSITORY: ${{ github.repository }} \ No newline at end of file -- 2.49.1 From cf407ff28fe059cfb1cf85ba274209725076f8cc Mon Sep 17 00:00:00 2001 From: WorldTeacher Date: Wed, 19 Nov 2025 15:55:17 +0100 Subject: [PATCH 3/5] chore: remove arm build --- .gitea/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1f9c846..e3dbb65 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -98,7 +98,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} -- 2.49.1 From a7ab44d67b8521b694709505415ee3ca903487d8 Mon Sep 17 00:00:00 2001 From: WorldTeacher Date: Wed, 19 Nov 2025 15:56:36 +0100 Subject: [PATCH 4/5] feat: change docker image creation --- .dockerignore | 4 ++++ Dockerfile | 13 +++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index 782a267..889c9d9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,7 @@ venv/ .gitignore node_modules/ uv.lock +test.py +result.xml +README.md +.gitea/ diff --git a/Dockerfile b/Dockerfile index 7207b86..2c9f603 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,20 @@ # syntax=docker/dockerfile:1 -FROM python:3.13 +FROM python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + # Playwright won't be installed for actual browser automation + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 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 -r requirements.txt && \ + # Clean up pip cache + rm -rf /root/.cache/pip # Copy application code COPY app ./app -- 2.49.1 From 8ad45cd775c6c1a94a1c97fdc1506c0b719d8c4d Mon Sep 17 00:00:00 2001 From: WorldTeacher Date: Wed, 19 Nov 2025 16:03:02 +0100 Subject: [PATCH 5/5] test: add gitea pypi index url --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2c9f603..19f8deb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,9 @@ WORKDIR /app # 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 -- 2.49.1