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:
@@ -11,3 +11,7 @@ venv/
|
||||
.gitignore
|
||||
node_modules/
|
||||
uv.lock
|
||||
test.py
|
||||
result.xml
|
||||
README.md
|
||||
.gitea/
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -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
|
||||
|
||||
39
app/main.py
39
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/"
|
||||
|
||||
Reference in New Issue
Block a user