Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f372f5056 | |||
| c078de0076 | |||
| daccf3334b | |||
| ef605e080b | |||
| 1755ec222d | |||
| 06ab3e1f4d | |||
| a2c79c80bc | |||
| 7ac66ae3ba | |||
| 444a1df1aa | |||
| 0581b5c5e9 |
Binary file not shown.
+865
-59
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -25,7 +25,6 @@ Sammlungsstruktur:
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from pymongo import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
import pytz
|
||||
@@ -34,6 +33,7 @@ import os
|
||||
import json
|
||||
import shutil
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
|
||||
# Add this helper function after imports
|
||||
def ensure_timezone_aware(dt):
|
||||
|
||||
+1
-1
@@ -26,10 +26,10 @@ Collection Structure:
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from pymongo import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
|
||||
|
||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
|
||||
|
||||
@@ -10,4 +10,5 @@ python-dateutil
|
||||
pytz
|
||||
requests
|
||||
reportlab
|
||||
python-barcode
|
||||
python-barcode
|
||||
openpyxl
|
||||
@@ -12,6 +12,9 @@ defaults for the web application and helper modules.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from functools import lru_cache
|
||||
|
||||
from pymongo import MongoClient as _PyMongoClient
|
||||
|
||||
# Base directory of this Web package
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -173,3 +176,21 @@ if not os.path.isabs(BACKUP_FOLDER):
|
||||
BACKUP_FOLDER = os.path.join(PROJECT_ROOT, BACKUP_FOLDER)
|
||||
if not os.path.isabs(LOGS_FOLDER):
|
||||
LOGS_FOLDER = os.path.join(PROJECT_ROOT, LOGS_FOLDER)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _shared_mongo_client():
|
||||
return _PyMongoClient(
|
||||
MONGODB_HOST,
|
||||
MONGODB_PORT,
|
||||
maxPoolSize=10,
|
||||
minPoolSize=0,
|
||||
connectTimeoutMS=5000,
|
||||
serverSelectionTimeoutMS=5000,
|
||||
retryWrites=True,
|
||||
)
|
||||
|
||||
|
||||
def MongoClient(*args, **kwargs):
|
||||
"""Return a shared MongoDB client configured from this settings module."""
|
||||
return _shared_mongo_client()
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
<h1>Audit Dashboard</h1>
|
||||
<p>Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).</p>
|
||||
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap; margin:10px 0 18px;">
|
||||
<a class="btn btn-primary" href="{{ url_for('admin_audit_export', format='md') }}">Audit Review exportieren (Markdown)</a>
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='json') }}">Audit Review exportieren (JSON)</a>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
|
||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
||||
<div style="font-size:0.85rem; color:#64748b;">Chain Status</div>
|
||||
|
||||
@@ -290,6 +290,20 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.library-load-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.library-load-hint {
|
||||
font-size: 0.9em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal {
|
||||
display: none;
|
||||
@@ -501,6 +515,10 @@
|
||||
<!-- Items will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="loadMoreRow" class="library-load-row" style="display:none;">
|
||||
<span id="loadInfo" class="library-load-hint"></span>
|
||||
<button id="loadMoreBtn" class="button" type="button">Mehr laden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
@@ -523,6 +541,24 @@
|
||||
// State
|
||||
let libraryItems = [];
|
||||
let filteredItems = [];
|
||||
let visibleItems = [];
|
||||
let currentSearchTerm = '';
|
||||
let activeFilters = {
|
||||
author: '',
|
||||
isbn: '',
|
||||
type: '',
|
||||
status: ''
|
||||
};
|
||||
let pagingState = {
|
||||
offset: 0,
|
||||
total: 0,
|
||||
hasMore: true,
|
||||
loading: false
|
||||
};
|
||||
const API_PAGE_SIZE = 120;
|
||||
const INITIAL_RENDER_COUNT = 120;
|
||||
const RENDER_BATCH_COUNT = 120;
|
||||
let renderedCount = INITIAL_RENDER_COUNT;
|
||||
let filterPanelOpen = false;
|
||||
let scannerInstance = null;
|
||||
let scannerRunning = false;
|
||||
@@ -531,32 +567,124 @@
|
||||
let lastScanAt = 0;
|
||||
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1');
|
||||
|
||||
// Load items
|
||||
async function fetchLibraryPage(offset, limit) {
|
||||
const response = await fetch(`/api/library_items?offset=${offset}&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load library items');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Load items progressively: first page immediately, rest in background
|
||||
async function loadLibraryItems() {
|
||||
if (pagingState.loading) return;
|
||||
pagingState.loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/library_items');
|
||||
if (!response.ok) throw new Error('Failed to load library items');
|
||||
libraryItems = await response.json();
|
||||
displayItems(libraryItems);
|
||||
const firstPage = await fetchLibraryPage(0, API_PAGE_SIZE);
|
||||
libraryItems = firstPage.items || [];
|
||||
pagingState.offset = (firstPage.offset || 0) + (firstPage.count || libraryItems.length);
|
||||
pagingState.total = firstPage.total || libraryItems.length;
|
||||
pagingState.hasMore = Boolean(firstPage.has_more);
|
||||
|
||||
applyFiltersAndSearch(true);
|
||||
|
||||
if (pagingState.hasMore) {
|
||||
loadRemainingLibraryItemsInBackground();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading library items:', error);
|
||||
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="6" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
|
||||
} finally {
|
||||
pagingState.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Display items in table
|
||||
function displayItems(items) {
|
||||
async function loadRemainingLibraryItemsInBackground() {
|
||||
while (pagingState.hasMore) {
|
||||
try {
|
||||
const page = await fetchLibraryPage(pagingState.offset, API_PAGE_SIZE);
|
||||
const nextItems = page.items || [];
|
||||
if (nextItems.length === 0) {
|
||||
pagingState.hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
libraryItems = libraryItems.concat(nextItems);
|
||||
pagingState.offset = (page.offset || pagingState.offset) + (page.count || nextItems.length);
|
||||
pagingState.total = page.total || pagingState.total;
|
||||
pagingState.hasMore = Boolean(page.has_more);
|
||||
|
||||
// Keep the current view reactive while avoiding a full rerender burst.
|
||||
applyFiltersAndSearch(false);
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
await new Promise(resolve => requestIdleCallback(() => resolve(), { timeout: 250 }));
|
||||
} else {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading additional library items:', error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterItems(items) {
|
||||
const author = activeFilters.author;
|
||||
const isbn = activeFilters.isbn;
|
||||
const type = activeFilters.type;
|
||||
const status = activeFilters.status;
|
||||
|
||||
return items.filter(item => {
|
||||
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
|
||||
const isbnMatch = !isbn || (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(isbn);
|
||||
const typeMatch = !type || (item.ItemType || 'book') === type;
|
||||
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
|
||||
const statusMatch = !status || statusKey === status;
|
||||
return authorMatch && isbnMatch && typeMatch && statusMatch;
|
||||
});
|
||||
}
|
||||
|
||||
function applySearch(items) {
|
||||
if (!currentSearchTerm) return items;
|
||||
return items.filter(item =>
|
||||
(item.Name || '').toLowerCase().includes(currentSearchTerm) ||
|
||||
(item.Autor || item.Author || '').toLowerCase().includes(currentSearchTerm) ||
|
||||
(item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(currentSearchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
function applyFiltersAndSearch(resetRenderCount) {
|
||||
const filteredByPanel = filterItems(libraryItems);
|
||||
filteredItems = applySearch(filteredByPanel);
|
||||
if (resetRenderCount) {
|
||||
renderedCount = Math.min(INITIAL_RENDER_COUNT, filteredItems.length);
|
||||
} else {
|
||||
renderedCount = Math.min(Math.max(renderedCount, INITIAL_RENDER_COUNT), filteredItems.length || INITIAL_RENDER_COUNT);
|
||||
}
|
||||
visibleItems = filteredItems;
|
||||
renderItems();
|
||||
}
|
||||
|
||||
// Display current view with incremental rendering
|
||||
function renderItems() {
|
||||
const tbody = document.getElementById('itemsTableBody');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const loadMoreRow = document.getElementById('loadMoreRow');
|
||||
const loadInfo = document.getElementById('loadInfo');
|
||||
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
||||
|
||||
if (items.length === 0) {
|
||||
if (visibleItems.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
emptyState.style.display = 'block';
|
||||
loadMoreRow.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
tbody.innerHTML = items.map(item => `
|
||||
const rowsToRender = visibleItems.slice(0, renderedCount);
|
||||
|
||||
tbody.innerHTML = rowsToRender.map(item => `
|
||||
${(() => {
|
||||
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
|
||||
const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available');
|
||||
@@ -567,7 +695,7 @@
|
||||
<tr>
|
||||
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
|
||||
<td>${escapeHtml(item.Autor || item.Author || '-')}</td>
|
||||
<td>${escapeHtml(item.ISBN || item.Code4 || '-')}</td>
|
||||
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
|
||||
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
|
||||
<td>
|
||||
<span class="table-status ${statusClass}">
|
||||
@@ -585,6 +713,18 @@
|
||||
`;
|
||||
})()}
|
||||
`).join('');
|
||||
|
||||
const canLoadMoreRows = renderedCount < visibleItems.length;
|
||||
if (canLoadMoreRows) {
|
||||
loadMoreRow.style.display = 'flex';
|
||||
loadMoreBtn.style.display = 'inline-block';
|
||||
loadInfo.textContent = `${renderedCount} von ${visibleItems.length} angezeigt`;
|
||||
} else {
|
||||
const backgroundHint = pagingState.hasMore ? `Lade weitere Medien im Hintergrund (${libraryItems.length}/${pagingState.total || '?'})...` : `${visibleItems.length} Treffer`;
|
||||
loadMoreRow.style.display = 'flex';
|
||||
loadMoreBtn.style.display = 'none';
|
||||
loadInfo.textContent = backgroundHint;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter toggle
|
||||
@@ -598,21 +738,11 @@
|
||||
|
||||
// Apply filters
|
||||
document.getElementById('applyFilterBtn').addEventListener('click', () => {
|
||||
const author = document.getElementById('filterAuthor').value.toLowerCase();
|
||||
const isbn = document.getElementById('filterISBN').value.toLowerCase();
|
||||
const type = document.getElementById('filterType').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
|
||||
filteredItems = libraryItems.filter(item => {
|
||||
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
|
||||
const isbnMatch = !isbn || (item.ISBN || item.Code4 || '').toLowerCase().includes(isbn);
|
||||
const typeMatch = !type || (item.ItemType || 'book') === type;
|
||||
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
|
||||
const statusMatch = !status || statusKey === status;
|
||||
return authorMatch && isbnMatch && typeMatch && statusMatch;
|
||||
});
|
||||
|
||||
displayItems(filteredItems);
|
||||
activeFilters.author = document.getElementById('filterAuthor').value.toLowerCase();
|
||||
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
|
||||
activeFilters.type = document.getElementById('filterType').value;
|
||||
activeFilters.status = document.getElementById('filterStatus').value;
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
// Clear filters
|
||||
@@ -621,19 +751,19 @@
|
||||
document.getElementById('filterISBN').value = '';
|
||||
document.getElementById('filterType').value = '';
|
||||
document.getElementById('filterStatus').value = '';
|
||||
displayItems(libraryItems);
|
||||
activeFilters = { author: '', isbn: '', type: '', status: '' };
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
// Search input
|
||||
document.getElementById('librarySearch').addEventListener('input', (e) => {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
const toSearch = filteredItems.length > 0 ? filteredItems : libraryItems;
|
||||
const results = toSearch.filter(item =>
|
||||
(item.Name || '').toLowerCase().includes(searchTerm) ||
|
||||
(item.Autor || item.Author || '').toLowerCase().includes(searchTerm) ||
|
||||
(item.ISBN || item.Code4 || '').toLowerCase().includes(searchTerm)
|
||||
);
|
||||
displayItems(results);
|
||||
currentSearchTerm = (e.target.value || '').toLowerCase();
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
document.getElementById('loadMoreBtn').addEventListener('click', () => {
|
||||
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
|
||||
renderItems();
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
|
||||
+93
-9
@@ -724,8 +724,15 @@
|
||||
// Add this line to define allItems globally
|
||||
let allItems = [];
|
||||
|
||||
function loadItems() {
|
||||
fetch("{{ url_for('get_items') }}")
|
||||
const MAIN_ITEMS_PAGE_SIZE = 120;
|
||||
let mainItemsNextOffset = 0;
|
||||
let mainItemsHasMore = false;
|
||||
let mainItemsLoadingMore = false;
|
||||
let mainItemsObserver = null;
|
||||
let mainItemsSentinel = null;
|
||||
|
||||
function loadItems(offset = 0, append = false) {
|
||||
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
@@ -735,27 +742,39 @@
|
||||
const filter3Values = new Set();
|
||||
|
||||
// Store all items data globally for filter rebuilding
|
||||
allItems = data.items || [];
|
||||
const pageItems = data.items || [];
|
||||
allItems = append ? allItems.concat(pageItems) : pageItems;
|
||||
|
||||
// Clear the container first
|
||||
itemsContainer.innerHTML = '';
|
||||
if (!append) {
|
||||
itemsContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
if (!append && (!pageItems || pageItems.length === 0)) {
|
||||
itemsContainer.innerHTML = '<div class="no-items-message">Keine Objekte gefunden</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort items by name in ascending order (A-Z)
|
||||
data.items.sort((a, b) => {
|
||||
pageItems.sort((a, b) => {
|
||||
const nameA = a.Name ? a.Name.toLowerCase() : '';
|
||||
const nameB = b.Name ? b.Name.toLowerCase() : '';
|
||||
return nameA.localeCompare(nameB); // Ascending order
|
||||
});
|
||||
|
||||
allItems.forEach(itemForFilters => {
|
||||
const f1 = Array.isArray(itemForFilters.Filter) ? itemForFilters.Filter : (itemForFilters.Filter ? [itemForFilters.Filter] : []);
|
||||
const f2 = Array.isArray(itemForFilters.Filter2) ? itemForFilters.Filter2 : (itemForFilters.Filter2 ? [itemForFilters.Filter2] : []);
|
||||
const f3 = Array.isArray(itemForFilters.Filter3) ? itemForFilters.Filter3 : (itemForFilters.Filter3 ? [itemForFilters.Filter3] : []);
|
||||
f1.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter1Values.add(value); });
|
||||
f2.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter2Values.add(value); });
|
||||
f3.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter3Values.add(value); });
|
||||
});
|
||||
|
||||
const favoriteIds = new Set(data.favorites || []);
|
||||
window.currentFavorites = favoriteIds;
|
||||
try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(favoriteIds))); } catch(e){}
|
||||
data.items.forEach(item => {
|
||||
pageItems.forEach(item => {
|
||||
try {
|
||||
const card = document.createElement('div');
|
||||
card.classList.add('item-card');
|
||||
@@ -995,8 +1014,10 @@
|
||||
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
|
||||
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
|
||||
|
||||
// Start display from leftmost position (first card, which is now Z)
|
||||
itemsContainer.scrollLeft = 0;
|
||||
// Start display from leftmost position on full reload only.
|
||||
if (!append) {
|
||||
itemsContainer.scrollLeft = 0;
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
applyFilters();
|
||||
@@ -1011,15 +1032,78 @@
|
||||
if (activeFilters.filter1.length > 0 || activeFilters.filter2.length > 0) {
|
||||
rebuildFilter3Options();
|
||||
}
|
||||
|
||||
mainItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
|
||||
mainItemsHasMore = Boolean(data.has_more);
|
||||
setupMainItemsLazyLoading();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching items:', error);
|
||||
if (append) {
|
||||
return;
|
||||
}
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
itemsContainer.innerHTML =
|
||||
'<div class="error-message">Fehler beim Laden der Objekte. Bitte versuchen Sie es später erneut.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMainItemsSentinel() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer) return null;
|
||||
|
||||
if (!mainItemsSentinel) {
|
||||
mainItemsSentinel = document.createElement('div');
|
||||
mainItemsSentinel.id = 'main-items-sentinel';
|
||||
mainItemsSentinel.style.flex = '0 0 1px';
|
||||
mainItemsSentinel.style.width = '1px';
|
||||
mainItemsSentinel.style.minWidth = '1px';
|
||||
mainItemsSentinel.style.height = '1px';
|
||||
mainItemsSentinel.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
if (!mainItemsSentinel.parentNode) {
|
||||
itemsContainer.appendChild(mainItemsSentinel);
|
||||
} else {
|
||||
itemsContainer.appendChild(mainItemsSentinel);
|
||||
}
|
||||
|
||||
return itemsContainer;
|
||||
}
|
||||
|
||||
function setupMainItemsLazyLoading() {
|
||||
const itemsContainer = ensureMainItemsSentinel();
|
||||
if (!itemsContainer) return;
|
||||
|
||||
if (mainItemsObserver) {
|
||||
mainItemsObserver.disconnect();
|
||||
mainItemsObserver = null;
|
||||
}
|
||||
|
||||
if (!mainItemsHasMore) return;
|
||||
|
||||
mainItemsObserver = new IntersectionObserver(entries => {
|
||||
if (!entries.some(entry => entry.isIntersecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainItemsLoadingMore || !mainItemsHasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainItemsLoadingMore = true;
|
||||
loadItems(mainItemsNextOffset, true).finally(() => {
|
||||
mainItemsLoadingMore = false;
|
||||
});
|
||||
}, {
|
||||
root: itemsContainer,
|
||||
threshold: 0.15,
|
||||
rootMargin: '0px 220px 0px 0px'
|
||||
});
|
||||
|
||||
mainItemsObserver.observe(mainItemsSentinel);
|
||||
}
|
||||
|
||||
function searchByCode() {
|
||||
const searchInput = document.getElementById('code-search');
|
||||
const rawSearchTerm = searchInput.value;
|
||||
|
||||
@@ -26,6 +26,106 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bulk-delete-inline-wrap {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.bulk-delete-drawer-toggle {
|
||||
position: static;
|
||||
transform: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.bulk-delete-drawer-toggle .drawer-icon {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.bulk-delete-drawer-toggle:hover {
|
||||
box-shadow: 0 10px 20px rgba(13, 110, 253, 0.3);
|
||||
}
|
||||
|
||||
.bulk-delete-drawer-toggle:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.bulk-delete-drawer {
|
||||
display: none;
|
||||
width: 100%;
|
||||
max-width: 980px;
|
||||
margin: 0 auto 18px;
|
||||
border: 1px solid #dbe3ee;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.bulk-delete-drawer.open {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bulk-delete-content {
|
||||
flex: 1 1 380px;
|
||||
}
|
||||
|
||||
.bulk-delete-drawer strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.bulk-delete-summary {
|
||||
color: #334155;
|
||||
font-size: 0.92rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bulk-delete-drawer small {
|
||||
color: #64748b;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.bulk-delete-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
align-items: center;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.bulk-delete-actions button {
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bulk-delete-actions button:hover {
|
||||
background: #eef4ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.bulk-delete-actions .danger {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
border-color: #c82333;
|
||||
}
|
||||
|
||||
.bulk-delete-actions .danger:hover {
|
||||
background: #c82333;
|
||||
border-color: #b21f2d;
|
||||
}
|
||||
|
||||
/* Filter styles */
|
||||
.filter-container {
|
||||
display: flex;
|
||||
@@ -237,6 +337,12 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-card.bulk-selected {
|
||||
border-color: #dc3545;
|
||||
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.18), 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
background: #fffdfd;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
@@ -257,6 +363,45 @@
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.bulk-delete-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bulk-delete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bulk-delete-toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #dc3545;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bulk-delete-drawer.open {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bulk-delete-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bulk-delete-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.item-card .item-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
@@ -2109,9 +2254,26 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<button id="toggle-favorites-view" class="view-toggle-btn" title="Nur Merkliste anzeigen">
|
||||
<span id="favorites-view-icon">🔖</span>
|
||||
</button>
|
||||
<div class="bulk-delete-inline-wrap">
|
||||
<button type="button" id="bulk-delete-drawer-toggle" class="view-toggle-btn bulk-delete-drawer-toggle" aria-expanded="false" title="Massenlöschung" onclick="toggleBulkDeleteDrawer()">
|
||||
<span class="drawer-icon">⚙</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</h1>
|
||||
<div id="items-indicator" class="items-indicator">Objekte im System: 0</div>
|
||||
<div id="bulk-delete-drawer" class="bulk-delete-drawer" aria-hidden="true">
|
||||
<div class="bulk-delete-content">
|
||||
<strong>Massenlöschung nach Kriterien</strong>
|
||||
<div class="bulk-delete-summary" id="bulk-delete-summary">0 Elemente ausgewählt</div>
|
||||
<small>Filter zuerst setzen, dann alle sichtbaren Elemente markieren oder einzelne Karten per Checkbox auswählen.</small>
|
||||
</div>
|
||||
<div class="bulk-delete-actions">
|
||||
<button type="button" onclick="selectVisibleItems()">Alle sichtbaren auswählen</button>
|
||||
<button type="button" onclick="clearBulkSelection()">Auswahl leeren</button>
|
||||
<button type="button" id="bulk-delete-button" class="danger" onclick="deleteSelectedItems()" disabled>Auswahl löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-container">
|
||||
<div class="filter-group">
|
||||
<div class="filter-header">
|
||||
@@ -2492,6 +2654,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
let descSearchIds = null; // Set of matching IDs or null when disabled
|
||||
let currentUsername = '';
|
||||
let allItems = []; // Cache for all items
|
||||
let bulkDeleteSelection = new Set();
|
||||
let bulkDeleteDrawerOpen = false;
|
||||
const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }};
|
||||
const studentDefaultBorrowDays = {{ student_default_borrow_days|default(14) }};
|
||||
const studentMaxBorrowDays = {{ student_max_borrow_days|default(365) }};
|
||||
@@ -2822,6 +2986,134 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
filter3: []
|
||||
};
|
||||
}
|
||||
|
||||
function updateBulkDeleteSummary() {
|
||||
const summary = document.getElementById('bulk-delete-summary');
|
||||
const button = document.getElementById('bulk-delete-button');
|
||||
const count = bulkDeleteSelection.size;
|
||||
|
||||
if (summary) {
|
||||
summary.textContent = `${count} ${count === 1 ? 'Element' : 'Elemente'} ausgewählt`;
|
||||
}
|
||||
if (button) {
|
||||
button.disabled = count === 0;
|
||||
button.textContent = count === 0 ? 'Auswahl löschen' : `Auswahl löschen (${count})`;
|
||||
}
|
||||
}
|
||||
|
||||
function setBulkDeleteDrawer(open) {
|
||||
bulkDeleteDrawerOpen = Boolean(open);
|
||||
const drawer = document.getElementById('bulk-delete-drawer');
|
||||
const toggle = document.getElementById('bulk-delete-drawer-toggle');
|
||||
if (drawer) {
|
||||
drawer.classList.toggle('open', bulkDeleteDrawerOpen);
|
||||
drawer.setAttribute('aria-hidden', bulkDeleteDrawerOpen ? 'false' : 'true');
|
||||
}
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', bulkDeleteDrawerOpen ? 'true' : 'false');
|
||||
toggle.innerHTML = bulkDeleteDrawerOpen ? '<span class="drawer-icon">✕</span>' : '<span class="drawer-icon">⚙</span>';
|
||||
toggle.title = bulkDeleteDrawerOpen ? 'Massenlöschung schließen' : 'Massenlöschung öffnen';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBulkDeleteDrawer() {
|
||||
setBulkDeleteDrawer(!bulkDeleteDrawerOpen);
|
||||
}
|
||||
|
||||
function setBulkDeleteSelection(itemId, checked) {
|
||||
const normalizedId = String(itemId || '').trim();
|
||||
if (!normalizedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
bulkDeleteSelection.add(normalizedId);
|
||||
} else {
|
||||
bulkDeleteSelection.delete(normalizedId);
|
||||
}
|
||||
|
||||
document.querySelectorAll(`.item-card[data-item-id='${normalizedId}']`).forEach(card => {
|
||||
card.classList.toggle('bulk-selected', checked);
|
||||
const checkbox = card.querySelector('.bulk-delete-checkbox');
|
||||
if (checkbox && checkbox.checked !== checked) {
|
||||
checkbox.checked = checked;
|
||||
}
|
||||
});
|
||||
|
||||
updateBulkDeleteSummary();
|
||||
}
|
||||
|
||||
function selectVisibleItems() {
|
||||
document.querySelectorAll('.item-card').forEach(card => {
|
||||
if (card.style.display === 'none') {
|
||||
return;
|
||||
}
|
||||
const itemId = String(card.dataset.itemId || '').trim();
|
||||
if (!itemId) {
|
||||
return;
|
||||
}
|
||||
bulkDeleteSelection.add(itemId);
|
||||
card.classList.add('bulk-selected');
|
||||
const checkbox = card.querySelector('.bulk-delete-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
updateBulkDeleteSummary();
|
||||
}
|
||||
|
||||
function clearBulkSelection() {
|
||||
bulkDeleteSelection.clear();
|
||||
document.querySelectorAll('.item-card.bulk-selected').forEach(card => {
|
||||
card.classList.remove('bulk-selected');
|
||||
const checkbox = card.querySelector('.bulk-delete-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
});
|
||||
|
||||
updateBulkDeleteSummary();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape' && bulkDeleteDrawerOpen) {
|
||||
setBulkDeleteDrawer(false);
|
||||
}
|
||||
});
|
||||
|
||||
function deleteSelectedItems() {
|
||||
const selectedIds = [...bulkDeleteSelection];
|
||||
if (selectedIds.length === 0) {
|
||||
alert('Bitte zuerst mindestens ein Element auswählen.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Wirklich ${selectedIds.length} ausgewählte Elemente revisionssicher deaktivieren?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ url_for('bulk_delete_items') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ item_ids: selectedIds })
|
||||
})
|
||||
.then(async response => {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Fehler beim Sammellöschen.');
|
||||
}
|
||||
clearBulkSelection();
|
||||
alert(data.message || 'Auswahl gelöscht.');
|
||||
loadItems();
|
||||
})
|
||||
.catch(error => {
|
||||
alert(error.message || 'Fehler beim Sammellöschen.');
|
||||
});
|
||||
}
|
||||
|
||||
// Load predefined filter values for dropdowns
|
||||
function loadPredefinedFilterValues(filterNumber) {
|
||||
@@ -2963,8 +3255,15 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
});
|
||||
|
||||
// Function to load items from server
|
||||
function loadItems() {
|
||||
fetch("{{ url_for('get_items') }}")
|
||||
const MAIN_ADMIN_ITEMS_PAGE_SIZE = 120;
|
||||
let mainAdminItemsNextOffset = 0;
|
||||
let mainAdminItemsHasMore = false;
|
||||
let mainAdminItemsLoadingMore = false;
|
||||
let mainAdminItemsObserver = null;
|
||||
let mainAdminItemsSentinel = null;
|
||||
|
||||
function loadItems(offset = 0, append = false) {
|
||||
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
@@ -2974,9 +3273,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const filter1Values = new Set();
|
||||
const filter2Values = new Set();
|
||||
const filter3Values = new Set();
|
||||
allItems = data.items || [];
|
||||
itemsContainer.innerHTML = '';
|
||||
if (!data.items || data.items.length === 0) {
|
||||
const pageItems = data.items || [];
|
||||
allItems = append ? allItems.concat(pageItems) : pageItems;
|
||||
if (!append) {
|
||||
itemsContainer.innerHTML = '';
|
||||
}
|
||||
if (!append && (!pageItems || pageItems.length === 0)) {
|
||||
itemsContainer.innerHTML = `
|
||||
<div class="no-items-message">
|
||||
<div>Keine Objekte gefunden</div>
|
||||
@@ -2989,15 +3291,27 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
return;
|
||||
}
|
||||
if (itemsIndicator) {
|
||||
itemsIndicator.textContent = `Objekte im System: ${data.items.length}`;
|
||||
const shownCount = allItems.length;
|
||||
const totalCount = Number(data.total || shownCount);
|
||||
itemsIndicator.textContent = `Objekte im System: ${shownCount}${data.has_more ? ` / ${totalCount} (lädt...)` : ''}`;
|
||||
}
|
||||
data.items.sort((a, b) => {
|
||||
pageItems.sort((a, b) => {
|
||||
const nameA = a.Name ? a.Name.toLowerCase() : '';
|
||||
const nameB = a.Name ? a.Name.toLowerCase() : '';
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
allItems.forEach(itemForFilters => {
|
||||
const f1 = Array.isArray(itemForFilters.Filter) ? itemForFilters.Filter : (itemForFilters.Filter ? [itemForFilters.Filter] : []);
|
||||
const f2 = Array.isArray(itemForFilters.Filter2) ? itemForFilters.Filter2 : (itemForFilters.Filter2 ? [itemForFilters.Filter2] : []);
|
||||
const f3 = Array.isArray(itemForFilters.Filter3) ? itemForFilters.Filter3 : (itemForFilters.Filter3 ? [itemForFilters.Filter3] : []);
|
||||
f1.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter1Values.add(value); });
|
||||
f2.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter2Values.add(value); });
|
||||
f3.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter3Values.add(value); });
|
||||
});
|
||||
|
||||
const favoriteIds = new Set(data.favorites || []);
|
||||
data.items.forEach(item => {
|
||||
pageItems.forEach(item => {
|
||||
try {
|
||||
const card = document.createElement('div');
|
||||
card.classList.add('item-card');
|
||||
@@ -3148,6 +3462,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
${appointmentBadge}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="bulk-delete-row">
|
||||
<label class="bulk-delete-toggle">
|
||||
<input type="checkbox" class="bulk-delete-checkbox" data-item-id="${item._id}" ${bulkDeleteSelection.has(String(item._id)) ? 'checked' : ''} onchange="setBulkDeleteSelection('${item._id}', this.checked)">
|
||||
Für Löschung markieren
|
||||
</label>
|
||||
</div>
|
||||
${isAvailableForBorrow && !item.BlockedNow ?
|
||||
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
|
||||
${isGroupedItem ? `
|
||||
@@ -3223,8 +3543,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
|
||||
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
|
||||
|
||||
itemsContainer.scrollLeft = 0;
|
||||
if (!append) {
|
||||
itemsContainer.scrollLeft = 0;
|
||||
}
|
||||
applyFilters();
|
||||
updateBulkDeleteSummary();
|
||||
|
||||
// Setup proper image display for all cards
|
||||
setupCardImagesDisplay();
|
||||
@@ -3237,9 +3560,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
rebuildFilter3Options();
|
||||
}
|
||||
/* favorites render count removed */
|
||||
|
||||
mainAdminItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
|
||||
mainAdminItemsHasMore = Boolean(data.has_more);
|
||||
setupMainAdminItemsLazyLoading();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching items:', error);
|
||||
if (append) {
|
||||
return;
|
||||
}
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
const itemsIndicator = document.getElementById('items-indicator');
|
||||
itemsContainer.innerHTML =
|
||||
@@ -3250,6 +3580,57 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMainAdminItemsSentinel() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer) return null;
|
||||
|
||||
if (!mainAdminItemsSentinel) {
|
||||
mainAdminItemsSentinel = document.createElement('div');
|
||||
mainAdminItemsSentinel.id = 'main-admin-items-sentinel';
|
||||
mainAdminItemsSentinel.style.flex = '0 0 1px';
|
||||
mainAdminItemsSentinel.style.width = '1px';
|
||||
mainAdminItemsSentinel.style.minWidth = '1px';
|
||||
mainAdminItemsSentinel.style.height = '1px';
|
||||
mainAdminItemsSentinel.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
itemsContainer.appendChild(mainAdminItemsSentinel);
|
||||
return itemsContainer;
|
||||
}
|
||||
|
||||
function setupMainAdminItemsLazyLoading() {
|
||||
const itemsContainer = ensureMainAdminItemsSentinel();
|
||||
if (!itemsContainer) return;
|
||||
|
||||
if (mainAdminItemsObserver) {
|
||||
mainAdminItemsObserver.disconnect();
|
||||
mainAdminItemsObserver = null;
|
||||
}
|
||||
|
||||
if (!mainAdminItemsHasMore) return;
|
||||
|
||||
mainAdminItemsObserver = new IntersectionObserver(entries => {
|
||||
if (!entries.some(entry => entry.isIntersecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainAdminItemsLoadingMore || !mainAdminItemsHasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainAdminItemsLoadingMore = true;
|
||||
loadItems(mainAdminItemsNextOffset, true).finally(() => {
|
||||
mainAdminItemsLoadingMore = false;
|
||||
});
|
||||
}, {
|
||||
root: itemsContainer,
|
||||
threshold: 0.15,
|
||||
rootMargin: '0px 220px 0px 0px'
|
||||
});
|
||||
|
||||
mainAdminItemsObserver.observe(mainAdminItemsSentinel);
|
||||
}
|
||||
|
||||
function searchByCode() {
|
||||
const searchInput = document.getElementById('code-search');
|
||||
const rawSearchTerm = searchInput.value;
|
||||
|
||||
@@ -710,6 +710,28 @@
|
||||
|
||||
<div class="upload-container">
|
||||
<a href="{{ url_for(back_target|default('home_admin')) }}" class="nav-back-button">← Zurück zur Artikelübersicht</a>
|
||||
|
||||
{% if show_library_features %}
|
||||
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
|
||||
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3>
|
||||
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
|
||||
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||||
<input type="file" name="library_excel" accept=".xlsx" required>
|
||||
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
|
||||
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Bibliothek importieren</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
|
||||
<h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3>
|
||||
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
|
||||
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||||
<input type="file" name="inventory_excel" accept=".xlsx" required>
|
||||
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
|
||||
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="upload-form">
|
||||
<h1>{{ page_title|default('Artikel hochladen') }}</h1>
|
||||
|
||||
+1
-1
@@ -10,10 +10,10 @@ Provides methods for creating, validating, and retrieving user information.
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from pymongo import MongoClient
|
||||
import hashlib
|
||||
from bson.objectid import ObjectId
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
|
||||
|
||||
def normalize_student_card_id(card_id):
|
||||
|
||||
+2
-1
@@ -9,4 +9,5 @@ python-dateutil
|
||||
pytz
|
||||
requests
|
||||
reportlab
|
||||
python-barcode
|
||||
python-barcode
|
||||
openpyxl
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user