Merge pull request 'dev' (#16) from dev into main

Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
2025-11-19 15:08:28 +00:00
7 changed files with 353 additions and 32 deletions

View File

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

View File

@@ -2,24 +2,22 @@ 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:
@@ -27,10 +25,10 @@ jobs:
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
@@ -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
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}
- name: Build Changelog
id: build_changelog
uses: https://github.com/mikepenz/release-changelog-builder-action@v5
with:
platform: "gitea"
baseURL: "http://gitea:3000"
baseURL: "http://192.168.178.110:3000"
configuration: ".gitea/changelog_config.json"
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
- name: Create Gitea Release
if: ${{ github.event.inputs.github_release == 'true' }}
@@ -116,5 +131,3 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}

View File

@@ -1,15 +1,22 @@
# 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 \
--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,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

@@ -137,6 +137,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() {
const STORAGE_KEY = 'theme';
@@ -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/"