feat: calculate 15% limit for book(s), blocking submission if over the limit

This commit is contained in:
2025-11-19 15:12:39 +01:00
parent 51e3e2a39c
commit a2cca9f977
4 changed files with 298 additions and 1 deletions

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

View File

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

View File

@@ -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 @@
'<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>' +
@@ -287,13 +294,19 @@
'<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();
}
}
@@ -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);
}
});
}
}
</script>
</body>
</html>

View File

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