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/"