Compare commits

...

12 Commits

Author SHA1 Message Date
Aiirondev_dev c23e128d2e feat: Enhance permission handling in fallback endpoints for improved user experience 2026-04-17 22:54:21 +02:00
Aiirondev_dev b611173ea9 feat: Add admin functionality to anonymize stored names and implement name aliasing 2026-04-17 21:57:49 +02:00
Aiirondev_dev 5cf9a4f1dd feat: Implement user permission management with presets and dynamic access control 2026-04-17 21:14:30 +02:00
Aiirondev_dev 88a67160f2 feat: Refactor mobile navigation styles for improved layout and responsiveness 2026-04-17 20:58:19 +02:00
Aiirondev_dev 2068af106f feat: Enhance mobile navigation layout and styling for improved user experience 2026-04-17 20:30:43 +02:00
Aiirondev_dev 06c2270842 feat: Enhance mobile responsiveness for container and modal styles in main and admin templates 2026-04-17 19:18:46 +02:00
Aiirondev_dev 20556f3500 feat: Simplify scheduled job setup by removing dynamic update timing and buffer management 2026-04-17 19:08:45 +02:00
Aiirondev_dev 7f1d616bb3 feat: Improve mobile layout handling for item loading indicators and prefetching logic 2026-04-17 19:05:34 +02:00
Aiirondev_dev 09cea7a0f8 feat: Implement update scheduling with dynamic timing and update buffer management 2026-04-17 18:58:10 +02:00
Aiirondev_dev 061f975727 feat: Implement Redis caching for notification status and add Redis service to Docker 2026-04-17 18:54:22 +02:00
Aiirondev_dev 68f0efa296 feat: Enhance navbar responsiveness with compact and ultra-compact modes 2026-04-17 18:43:46 +02:00
Aiirondev_dev a27639a976 feat: Enhance CSRF protection and support for CSV file uploads across multiple templates 2026-04-17 18:33:55 +02:00
12 changed files with 1720 additions and 119 deletions
+687 -53
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -9,6 +9,7 @@ apscheduler
python-dateutil
pytz
requests
redis
reportlab
python-barcode
openpyxl
+6 -4
View File
@@ -155,10 +155,12 @@ select:focus {
@media (max-width: 900px) {
.container {
width: calc(100% - 18px);
padding: 14px;
margin: 10px auto;
border-radius: 10px;
width: 100%;
max-width: 100%;
padding: 12px 12px 18px;
margin: 0;
border-radius: 0;
box-sizing: border-box;
}
}
+454 -13
View File
@@ -14,6 +14,7 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>{% block title %}Inventarsystem{% endblock %}</title>
{% block head %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
@@ -24,6 +25,74 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/planned_appointments.css', v=ASSET_VERSION) }}">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function () {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfMeta ? csrfMeta.content : '';
if (!csrfToken) {
return;
}
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
function sameOrigin(url) {
try {
return new URL(url, window.location.href).origin === window.location.origin;
} catch (error) {
return false;
}
}
function ensureFormToken(form) {
const method = (form.getAttribute('method') || 'GET').toUpperCase();
if (safeMethods.has(method)) {
return;
}
const action = form.getAttribute('action') || window.location.href;
if (!sameOrigin(action)) {
return;
}
let tokenInput = form.querySelector('input[name="csrf_token"]');
if (!tokenInput) {
tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = 'csrf_token';
form.appendChild(tokenInput);
}
tokenInput.value = csrfToken;
}
document.addEventListener('submit', function (event) {
const form = event.target;
if (form && form.tagName === 'FORM') {
ensureFormToken(form);
}
}, true);
const originalFetch = window.fetch.bind(window);
window.fetch = function (resource, init) {
const options = init ? { ...init } : {};
const method = (options.method || 'GET').toUpperCase();
const targetUrl = resource instanceof Request ? resource.url : String(resource);
if (!safeMethods.has(method) && sameOrigin(targetUrl)) {
const headers = new Headers(resource instanceof Request ? resource.headers : undefined);
if (options.headers) {
new Headers(options.headers).forEach((value, key) => headers.set(key, value));
}
headers.set('X-CSRFToken', csrfToken);
headers.set('X-Requested-With', 'fetch');
options.headers = headers;
}
return originalFetch(resource, options);
};
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('form[method="post"], form[method="POST"]').forEach(ensureFormToken);
});
})();
</script>
<style>
/* ===== MODULE DETECTION & SETUP ===== */
:root {
@@ -67,6 +136,10 @@
top: 0;
z-index: 1900;
}
.navbar .container-fluid {
gap: 10px;
}
.navbar-brand {
font-weight: bold;
@@ -83,6 +156,51 @@
font-weight: 600;
padding: 0.6rem 0.85rem;
border-radius: 8px;
transition: padding .18s ease, font-size .18s ease;
}
.navbar.nav-compact .navbar-brand {
font-size: 1.18rem;
}
.navbar.nav-compact .navbar-nav .nav-link {
font-size: 0.93rem;
padding: 0.48rem 0.62rem;
}
.navbar.nav-compact .function-search-wrap {
width: min(320px, 34vw);
}
.navbar.nav-compact .function-search-input,
.navbar.nav-compact .function-search-btn {
min-height: 34px;
padding-top: 6px;
padding-bottom: 6px;
}
.navbar.nav-ultra-compact .navbar-brand {
font-size: 1.04rem;
}
.navbar.nav-ultra-compact .navbar-nav .nav-link {
font-size: 0.86rem;
padding: 0.4rem 0.52rem;
}
.navbar.nav-ultra-compact .function-search-wrap {
width: min(270px, 30vw);
}
.navbar.nav-ultra-compact .function-search-btn {
padding-left: 9px;
padding-right: 9px;
font-size: 0.82rem;
}
.navbar.nav-ultra-compact .navbar-text {
margin-right: 0.45rem !important;
font-size: 0.86rem;
}
.navbar-nav .nav-link.nav-active {
@@ -424,22 +542,194 @@
}
@media (max-width: 768px) {
.module-selector-bar {
padding: 8px 12px;
.navbar {
border-bottom-left-radius: 14px;
border-bottom-right-radius: 14px;
}
.navbar .container-fluid {
align-items: flex-start;
flex-wrap: wrap;
padding-top: 0.2rem;
padding-bottom: 0.35rem;
}
.navbar-brand {
order: 1;
flex: 1 1 auto;
min-width: 0;
font-size: 1.08rem;
line-height: 1.1;
display: flex;
align-items: center;
gap: 6px;
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
.navbar-toggler {
order: 2;
margin-left: auto;
flex: 0 0 auto;
align-self: flex-start;
}
.navbar-collapse {
order: 4;
width: 100%;
margin-top: 8px;
padding: 10px 10px 6px;
border-radius: 14px;
background: rgba(15, 23, 42, 0.18);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.navbar-collapse.show,
.navbar-collapse.collapsing {
display: block;
}
.navbar-nav {
width: 100%;
align-items: stretch;
gap: 8px;
padding-top: 2px;
}
.navbar-nav .nav-item {
width: 100%;
}
.navbar-nav .nav-link {
width: 100%;
justify-content: flex-start;
min-height: 46px;
padding: 0.8rem 0.95rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.navbar-nav .nav-link.nav-active,
.navbar-nav .nav-link.quick-link-pill,
.navbar-nav .nav-link.nav-priority-link {
background: rgba(255, 255, 255, 0.16);
}
.navbar-nav .nav-item.dropdown {
width: 100%;
}
.navbar-nav .nav-item.dropdown .nav-link.dropdown-toggle {
width: 100%;
justify-content: space-between;
}
.navbar-nav .dropdown-menu {
width: 100%;
margin-left: 0;
margin-top: 6px;
border-radius: 12px;
}
.module-selector-bar {
padding: 8px 10px;
gap: 8px;
flex-wrap: wrap;
}
.module-selector-bar .module-label {
display: none;
}
.module-selector-bar .module-tabs {
width: 100%;
gap: 8px;
}
.module-selector-bar .module-tab {
padding: 5px 12px;
font-size: 0.9rem;
width: 100%;
text-align: center;
padding: 8px 12px;
font-size: 0.92rem;
}
.module-separator {
height: 18px;
display: none;
}
.function-search-wrap {
order: 3;
width: 100%;
margin: 0;
}
.function-search-form {
width: 100%;
flex-wrap: nowrap;
gap: 8px;
}
.function-search-input {
flex: 1 1 auto;
min-width: 0;
min-height: 44px;
}
.function-search-btn {
flex: 0 0 auto;
min-height: 44px;
min-width: 72px;
white-space: nowrap;
}
.navbar-text {
order: 5;
width: 100%;
margin: 0 !important;
padding: 8px 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
text-align: center;
}
.user-menu-wrap {
order: 6;
width: 100%;
margin-right: 0 !important;
}
.user-menu-btn {
width: 100%;
min-height: 46px;
border-radius: 12px;
justify-content: center;
}
.dropdown-menu-end {
width: 100%;
}
@media (max-width: 520px) {
.function-search-form {
flex-direction: column;
}
.function-search-input,
.function-search-btn,
.user-menu-btn,
.module-selector-bar .module-tab {
width: 100%;
}
.module-selector-bar .module-tabs {
flex-direction: column;
}
.navbar-text {
font-size: 0.88rem;
}
}
}
</style>
@@ -479,18 +769,24 @@
</button>
<div class="collapse navbar-collapse" id="inventoryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
{% if current_permissions.pages.get('home', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
</li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_manage_settings', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}"> Hochladen</a>
</li>
@@ -501,24 +797,46 @@
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li>
{% if current_permissions.pages.get('manage_filters', True) %}
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('manage_locations', True) %}
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_borrowings', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_damaged_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% endif %}
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('admin_audit_dashboard', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
{% endif %}
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('logs', True) %}
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% if current_permissions.actions.get('can_manage_users', True) %}
<li><h6 class="dropdown-header">System</h6></li>
{% if current_permissions.pages.get('user_del', True) %}
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('register', True) %}
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul>
@@ -547,7 +865,9 @@
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
{% if current_permissions.pages.get('notifications_view', True) %}
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
@@ -570,18 +890,24 @@
</button>
<div class="collapse navbar-collapse" id="libraryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
{% if current_permissions.pages.get('library_view', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
</li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) and current_permissions.pages.get('library_admin', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
</li>
@@ -592,23 +918,37 @@
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', True) %}
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_damaged_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% endif %}
{% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% if current_permissions.actions.get('can_manage_users', True) %}
<li><h6 class="dropdown-header">System</h6></li>
{% if current_permissions.pages.get('user_del', True) %}
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('register', True) %}
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul>
@@ -637,7 +977,9 @@
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown">
{% if current_permissions.pages.get('notifications_view', True) %}
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
@@ -1164,6 +1506,27 @@
function initNavbarOverflow(navList, navOverflowAnchor) {
if (!navList || !navOverflowAnchor) return;
const navRoot = navList.closest('nav.navbar');
const navCollapse = navList.closest('.navbar-collapse');
const navContainer = navList.closest('.container-fluid') || navList.parentElement;
function applyCompactMode() {
if (!navRoot || !navContainer) return;
const width = navContainer.clientWidth || window.innerWidth;
navRoot.classList.remove('nav-compact', 'nav-ultra-compact');
if (window.innerWidth < 992) {
return;
}
if (width < 1220) {
navRoot.classList.add('nav-compact');
}
if (width < 1080) {
navRoot.classList.add('nav-ultra-compact');
}
}
function collectTopLevelNavSources() {
if (!navList) return [];
return Array.from(navList.children).filter(function(li){
@@ -1198,13 +1561,67 @@
li.className = 'nav-item dropdown';
li.dataset.overflowControl = 'true';
const toggleId = navOverflowAnchor.id + '-toggle';
const menuId = navOverflowAnchor.id + '-menu';
li.innerHTML =
'<a class="nav-link dropdown-toggle" href="#" id="overflowMenuToggle" role="button" data-bs-toggle="dropdown" aria-expanded="false">⋮ Weitere</a>' +
'<ul class="dropdown-menu" aria-labelledby="overflowMenuToggle" id="overflowMenu"></ul>';
'<a class="nav-link dropdown-toggle" href="#" id="' + toggleId + '" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-controls="' + menuId + '">⋮ Weitere</a>' +
'<ul class="dropdown-menu" aria-labelledby="' + toggleId + '" id="' + menuId + '"></ul>';
return li;
}
function getNavCandidatePriority(item) {
if (!(item instanceof HTMLElement)) {
return -1;
}
const topLink = item.querySelector(':scope > a.nav-link');
if (!topLink) {
return 10;
}
if (topLink.classList.contains('nav-active')) {
return 1000;
}
if (topLink.classList.contains('nav-priority-link')) {
return 900;
}
if (topLink.classList.contains('quick-link-pill')) {
return 700;
}
if (item.classList.contains('dropdown')) {
return 300;
}
return 500;
}
function pickNextNavItemToHide(candidates) {
if (!Array.isArray(candidates) || candidates.length === 0) {
return null;
}
let selected = candidates[0];
let selectedIndex = 0;
let selectedPriority = getNavCandidatePriority(selected);
for (let i = 1; i < candidates.length; i += 1) {
const candidate = candidates[i];
const candidatePriority = getNavCandidatePriority(candidate);
if (candidatePriority < selectedPriority || (candidatePriority === selectedPriority && i > selectedIndex)) {
selected = candidate;
selectedIndex = i;
selectedPriority = candidatePriority;
}
}
return selected;
}
function appendSourceToOverflowMenu(sourceItem, menu) {
const topLink = sourceItem.querySelector(':scope > a.nav-link');
if (!topLink) return;
@@ -1240,6 +1657,10 @@
const control = createOverflowControl();
const menu = control.querySelector('ul.dropdown-menu');
const controlLink = control.querySelector(':scope > a.nav-link');
if (controlLink) {
controlLink.textContent = '⋮ Weitere (' + hiddenSources.length + ')';
}
hiddenSources.forEach(function(source){
appendSourceToOverflowMenu(source, menu);
@@ -1256,6 +1677,8 @@
function adaptNavbarByWidth() {
if (!navList || !navOverflowAnchor) return;
applyCompactMode();
if (window.innerWidth < 992) {
restoreAllNavItems();
return;
@@ -1267,7 +1690,10 @@
let candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
const toHide = candidates[candidates.length - 1];
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
}
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
candidates = collectTopLevelNavSources();
@@ -1277,7 +1703,10 @@
candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
const toHide = candidates[candidates.length - 1];
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
}
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
rebuildOverflowControl(hiddenSources);
@@ -1295,6 +1724,18 @@
adaptNavbarByWidth();
window.addEventListener('resize', debounceAdapt);
if (navCollapse) {
navCollapse.addEventListener('shown.bs.collapse', debounceAdapt);
navCollapse.addEventListener('hidden.bs.collapse', debounceAdapt);
}
if ('ResizeObserver' in window && navContainer) {
const resizeObserver = new ResizeObserver(function() {
debounceAdapt();
});
resizeObserver.observe(navContainer);
}
}
// Initialize overflow control for inventory navbar
+46 -11
View File
@@ -774,8 +774,18 @@
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
const styles = window.getComputedStyle(itemsContainer);
const isMobileLayout =
window.matchMedia('(max-width: 768px)').matches ||
styles.display === 'grid' ||
styles.flexDirection === 'column';
const distanceToEnd = isMobileLayout
? itemsContainer.scrollHeight - (itemsContainer.scrollTop + itemsContainer.clientHeight)
: itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = isMobileLayout
? Math.max(220, itemsContainer.clientHeight * 0.9)
: Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
}
@@ -1142,6 +1152,12 @@
const itemsContainer = ensureMainItemsSentinel();
if (!itemsContainer) return;
const styles = window.getComputedStyle(itemsContainer);
const isMobileLayout =
window.matchMedia('(max-width: 768px)').matches ||
styles.display === 'grid' ||
styles.flexDirection === 'column';
if (mainItemsObserver) {
mainItemsObserver.disconnect();
mainItemsObserver = null;
@@ -1172,7 +1188,7 @@
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
rootMargin: isMobileLayout ? '0px 0px 900px 0px' : '0px 900px 0px 0px'
});
mainItemsObserver.observe(mainItemsSentinel);
@@ -2942,6 +2958,22 @@
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@media (max-width: 768px) {
.items-loading-indicator {
width: 100%;
min-width: 0;
max-width: none;
min-height: 120px;
height: auto;
padding: 14px 10px;
scroll-snap-align: none;
}
.items-loading-indicator .loading-track {
width: min(240px, 82%);
}
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
@@ -3342,9 +3374,12 @@
/* Mobile-responsive styles for user interface */
.container {
width: 95% !important;
margin: 10px auto !important;
padding: 15px !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 12px 12px 18px !important;
border-radius: 0 !important;
box-sizing: border-box !important;
}
h1, h2 {
@@ -3414,7 +3449,7 @@
display: grid !important;
grid-template-columns: 1fr !important;
gap: 15px !important;
padding: 10px 0 !important;
padding: 10px 0 0 !important;
overflow-x: visible !important;
}
@@ -3431,12 +3466,12 @@
/* Modal improvements for mobile */
.modal-content {
width: 95% !important;
max-width: 500px !important;
margin: 20px auto !important;
width: calc(100vw - 16px) !important;
max-width: none !important;
margin: 8px auto !important;
max-height: 85vh !important;
overflow-y: auto !important;
padding: 20px !important;
padding: 16px !important;
}
/* Button improvements */
+67 -27
View File
@@ -369,6 +369,22 @@
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@media (max-width: 768px) {
.items-loading-indicator {
width: 100%;
min-width: 0;
max-width: none;
min-height: 120px;
height: auto;
padding: 14px 10px;
scroll-snap-align: none;
}
.items-loading-indicator .loading-track {
width: min(240px, 82%);
}
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
@@ -1221,9 +1237,11 @@
/* Mobile-responsive styles for admin interface */
.admin-content-container {
width: 95% !important;
margin: 10px auto !important;
padding: 15px !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 12px 12px 18px !important;
box-sizing: border-box !important;
overflow-x: hidden !important;
}
@@ -1347,11 +1365,12 @@
/* Modal improvements for admin */
.modal-content {
width: 95% !important;
max-width: 500px !important;
margin: 20px auto !important;
width: calc(100vw - 16px) !important;
max-width: none !important;
margin: 8px auto !important;
max-height: 85vh !important;
overflow-y: auto !important;
padding: 16px !important;
}
/* Admin card improvements */
@@ -1510,9 +1529,11 @@
/* Mobile-responsive styles for admin interface */
@media screen and (max-width: 768px) {
.admin-content-container {
width: 95% !important;
margin: 10px auto !important;
padding: 15px !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 12px 12px 18px !important;
box-sizing: border-box !important;
}
h1, h2 {
@@ -1599,12 +1620,12 @@
/* Modal improvements for admin */
.modal-content {
width: 95% !important;
max-width: 500px !important;
margin: 20px auto !important;
width: calc(100vw - 16px) !important;
max-width: none !important;
margin: 8px auto !important;
max-height: 85vh !important;
overflow-y: auto !important;
padding: 20px !important;
padding: 16px !important;
}
/* Button improvements */
@@ -1792,9 +1813,11 @@
}
.admin-content-container {
width: 95%;
margin: 10px auto;
padding: 15px;
width: 100%;
max-width: 100%;
margin: 0;
padding: 12px 12px 18px;
box-sizing: border-box;
overflow-x: hidden;
}
@@ -1808,12 +1831,13 @@
/* Better modal sizing for mobile */
.modal-content {
width: 95% !important;
width: calc(100vw - 16px) !important;
max-width: none !important;
margin: 10px auto !important;
margin: 8px auto !important;
max-height: 90vh !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
padding: 16px !important;
}
}
@@ -3366,8 +3390,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
return;
}
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
const styles = window.getComputedStyle(itemsContainer);
const isMobileLayout =
window.matchMedia('(max-width: 768px)').matches ||
styles.display === 'grid' ||
styles.flexDirection === 'column';
const distanceToEnd = isMobileLayout
? itemsContainer.scrollHeight - (itemsContainer.scrollTop + itemsContainer.clientHeight)
: itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
const prefetchThreshold = isMobileLayout
? Math.max(220, itemsContainer.clientHeight * 0.9)
: Math.max(260, itemsContainer.clientWidth * 1.4);
if (distanceToEnd > prefetchThreshold) {
return;
}
@@ -3609,9 +3643,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
:
`<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
}
<a href="{{ url_for('delete_item', id='') }}${item._id}" onclick="return confirm('Sind Sie sicher, dass Sie dieses Objekt löschen möchten?')">
<button class="delete-button">Löschen</button>
</a>
<form method="POST" action="{{ url_for('delete_item', id='') }}${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher, dass Sie dieses Objekt löschen möchten?')">
<button class="delete-button" type="submit">Löschen</button>
</form>
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-card-${item._id}')">Bearbeiten</button>
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
@@ -3729,6 +3763,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
const itemsContainer = ensureMainAdminItemsSentinel();
if (!itemsContainer) return;
const styles = window.getComputedStyle(itemsContainer);
const isMobileLayout =
window.matchMedia('(max-width: 768px)').matches ||
styles.display === 'grid' ||
styles.flexDirection === 'column';
if (mainAdminItemsObserver) {
mainAdminItemsObserver.disconnect();
mainAdminItemsObserver = null;
@@ -3759,7 +3799,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
rootMargin: isMobileLayout ? '0px 0px 900px 0px' : '0px 900px 0px 0px'
});
mainAdminItemsObserver.observe(mainAdminItemsSentinel);
@@ -4420,9 +4460,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${damageReports.length > 0 ? `<button class="damage-button" onclick="markDamageAsRepaired('${item._id}')">Repariert</button>` : `<button class="damage-button" onclick="registerDamage('${item._id}')">Schaden melden</button>`}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
<a href="/delete_item/${item._id}" onclick="return confirm('Sind Sie sicher?')">
<button class="delete-button">Löschen</button>
</a>
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
<button class="delete-button" type="submit">Löschen</button>
</form>
</div>
`;
+2 -2
View File
@@ -239,9 +239,9 @@
<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 Bibliotheksausweise</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch, zum Beispiel aus <strong>ASV (Amtliche Schuldaten)</strong>. Erkannt werden automatisch Spalten wie <strong>Name</strong>, <strong>Klasse</strong>, <strong>Ausweis-ID</strong>, <strong>Notizen</strong> und <strong>Standard-Ausleihdauer</strong>. Fehlt die Ausweis-ID, wird sie automatisch aus Name und Klasse erzeugt.</p>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch, zum Beispiel aus <strong>ASV (Amtliche Schuldaten)</strong>. Erkannt werden automatisch Spalten wie <strong>Name</strong>, <strong>Klasse</strong>, <strong>Ausweis-ID</strong>, <strong>Notizen</strong> und <strong>Standard-Ausleihdauer</strong>. Fehlt die Ausweis-ID, wird sie automatisch aus Name und Klasse erzeugt.</p>
<form method="POST" action="{{ url_for('upload_student_cards_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="student_cards_excel" accept=".xlsx" required>
<input type="file" name="student_cards_excel" accept=".xlsx,.csv" 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">Ausweise importieren</button>
</form>
+4 -4
View File
@@ -714,9 +714,9 @@
{% 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>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</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>
<input type="file" name="library_excel" accept=".xlsx,.csv" 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>
@@ -724,9 +724,9 @@
{% 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>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</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>
<input type="file" name="inventory_excel" accept=".xlsx,.csv" 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>
+160
View File
@@ -17,6 +17,16 @@
<div class="user-management-container">
<h2>Benutzer</h2>
<form method="POST" action="{{ url_for('admin_anonymize_names') }}" class="mb-3">
<button
type="submit"
class="btn btn-outline-danger"
onclick="return confirm('Sollen alle gespeicherten Klarnamen dauerhaft in Synonym-Kuerzel umgewandelt werden?')"
>
Gespeicherte Namen anonymisieren
</button>
</form>
<div class="filter-bar mb-3">
<div class="row g-2 align-items-end">
<div class="col-md-3">
@@ -62,6 +72,7 @@
<th>Vorname</th>
<th>Nachname</th>
<th>Administrator</th>
<th>Rechte-Preset</th>
<th>Aktionen</th>
</tr>
</thead>
@@ -72,6 +83,7 @@
<td>{{ user.name if user.name else user.username }}</td>
<td>{{ user.last_name if user.last_name else '' }}</td>
<td>{{ "Ja" if user.admin else "Nein" }}</td>
<td>{{ permission_presets.get(user.permission_preset, {}).get('label', user.permission_preset) }}</td>
<td class="actions">
<form method="POST" action="{{ url_for('delete_user') }}" class="d-inline">
<input type="hidden" name="username" value="{{ user.username }}">
@@ -90,6 +102,14 @@
onclick="openResetPasswordModal('{{ user.username }}')">
Passwort zurücksetzen
</button>
<button type="button" class="btn btn-info btn-sm"
data-username="{{ user.username }}"
data-preset="{{ user.permission_preset }}"
data-action-permissions='{{ user.action_permissions | tojson }}'
data-page-permissions='{{ user.page_permissions | tojson }}'
onclick="openPermissionsModal(this)">
Berechtigungen
</button>
</td>
</tr>
{% endfor %}
@@ -99,6 +119,62 @@
</div>
</div>
<!-- Permission Modal -->
<div class="modal fade" id="permissionsModal" tabindex="-1" aria-labelledby="permissionsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="permissionsModalLabel">Berechtigungen bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ url_for('admin_update_user_permissions') }}">
<div class="modal-body permissions-modal-body">
<input type="hidden" id="perm-username" name="username">
<div class="mb-3">
<label for="perm-username-display" class="form-label">Benutzer</label>
<input type="text" class="form-control" id="perm-username-display" disabled>
</div>
<div class="mb-3">
<label for="permission-preset" class="form-label">Preset</label>
<select class="form-select" id="permission-preset" name="permission_preset">
{% for preset_key, preset_value in permission_presets.items() %}
<option value="{{ preset_key }}">{{ preset_value.label }}</option>
{% endfor %}
</select>
</div>
<div class="permissions-grid">
<div class="permissions-group">
<h6>Aktionsrechte</h6>
{% for action_key, action_label in permission_action_options %}
<div class="form-check">
<input class="form-check-input permission-action-checkbox" type="checkbox" id="action-{{ action_key }}" name="action_{{ action_key }}">
<label class="form-check-label" for="action-{{ action_key }}">{{ action_label }}</label>
</div>
{% endfor %}
</div>
<div class="permissions-group">
<h6>Seitenrechte</h6>
{% for endpoint_name, endpoint_label in permission_page_options %}
<div class="form-check">
<input class="form-check-input permission-page-checkbox" type="checkbox" id="page-{{ endpoint_name }}" name="page_{{ endpoint_name }}">
<label class="form-check-label" for="page-{{ endpoint_name }}">{{ endpoint_label }}</label>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Berechtigungen speichern</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit User Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1" aria-labelledby="editUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
@@ -165,6 +241,24 @@
</div>
<script>
var permissionPresets = {{ permission_presets | tojson }};
function applyPresetToPermissionForm(presetKey) {
var preset = permissionPresets[presetKey] || {};
var actionDefaults = preset.actions || {};
var pageDefaults = preset.pages || {};
document.querySelectorAll('.permission-action-checkbox').forEach(function (checkbox) {
var key = checkbox.name.replace('action_', '');
checkbox.checked = !!actionDefaults[key];
});
document.querySelectorAll('.permission-page-checkbox').forEach(function (checkbox) {
var key = checkbox.name.replace('page_', '');
checkbox.checked = !!pageDefaults[key];
});
}
function applyFilters() {
var search = document.getElementById('filter-search').value.toLowerCase();
var adminFilter = document.getElementById('filter-admin').value.toLowerCase();
@@ -229,6 +323,13 @@
document.getElementById('filter-admin').addEventListener('change', applyFilters);
document.getElementById('filter-column').addEventListener('change', applyFilters);
document.getElementById('filter-direction').addEventListener('change', applyFilters);
var presetSelect = document.getElementById('permission-preset');
if (presetSelect) {
presetSelect.addEventListener('change', function () {
applyPresetToPermissionForm(this.value);
});
}
});
function openEditUserModal(button) {
@@ -253,6 +354,42 @@
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
modal.show();
}
function openPermissionsModal(button) {
var username = button.getAttribute('data-username') || '';
var preset = button.getAttribute('data-preset') || 'standard_user';
var actionPermissions = {};
var pagePermissions = {};
try {
actionPermissions = JSON.parse(button.getAttribute('data-action-permissions') || '{}');
} catch (err) {
actionPermissions = {};
}
try {
pagePermissions = JSON.parse(button.getAttribute('data-page-permissions') || '{}');
} catch (err) {
pagePermissions = {};
}
document.getElementById('perm-username').value = username;
document.getElementById('perm-username-display').value = username;
document.getElementById('permission-preset').value = preset;
document.querySelectorAll('.permission-action-checkbox').forEach(function (checkbox) {
var key = checkbox.name.replace('action_', '');
checkbox.checked = !!actionPermissions[key];
});
document.querySelectorAll('.permission-page-checkbox').forEach(function (checkbox) {
var key = checkbox.name.replace('page_', '');
checkbox.checked = !!pagePermissions[key];
});
var modal = new bootstrap.Modal(document.getElementById('permissionsModal'));
modal.show();
}
</script>
<style>
@@ -286,5 +423,28 @@
.password-requirements p {
margin-bottom: 5px;
}
.permissions-modal-body {
max-height: 70vh;
overflow-y: auto;
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.permissions-group {
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 12px;
background: #f8fafc;
}
.permissions-group h6 {
font-weight: 700;
margin-bottom: 10px;
}
</style>
{% endblock %}
+272 -5
View File
@@ -11,6 +11,8 @@ Provides methods for creating, validating, and retrieving user information.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
import hashlib
import copy
import re
from bson.objectid import ObjectId
import settings as cfg
from settings import MongoClient
@@ -23,6 +25,252 @@ def normalize_student_card_id(card_id):
return str(card_id).strip().upper()
def _clean_name_fragment(value):
cleaned = re.sub(r'[^A-Za-zÄÖÜäöüß]', '', str(value or '').strip())
if not cleaned:
return ''
replacements = {
'ä': 'ae',
'ö': 'oe',
'ü': 'ue',
'ß': 'ss',
'Ä': 'Ae',
'Ö': 'Oe',
'Ü': 'Ue',
}
for old_char, new_char in replacements.items():
cleaned = cleaned.replace(old_char, new_char)
return cleaned
def build_name_synonym(first_name, last_name=''):
"""Build a deterministic, non-personalized short alias like 'SimFri'."""
first = _clean_name_fragment(first_name)
last = _clean_name_fragment(last_name)
if first and last:
return (first[:3] + last[:3]).title()
combined = (first + last)
if not combined:
return 'User'
return combined[:6].title()
ACTION_PERMISSION_KEYS = (
'can_borrow',
'can_insert',
'can_edit',
'can_delete',
'can_manage_users',
'can_manage_settings',
'can_view_logs',
)
DEFAULT_ACTION_PERMISSIONS = {
'can_borrow': True,
'can_insert': False,
'can_edit': False,
'can_delete': False,
'can_manage_users': False,
'can_manage_settings': False,
'can_view_logs': False,
}
DEFAULT_PAGE_PERMISSIONS = {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'home_admin': False,
'upload_admin': False,
'library_admin': False,
'admin_borrowings': False,
'library_loans_admin': False,
'admin_damaged_items': False,
'admin_audit_dashboard': False,
'logs': False,
'user_del': False,
'register': False,
'manage_filters': False,
'manage_locations': False,
}
PERMISSION_PRESETS = {
'standard_user': {
'label': 'Standard (Ausleihe)',
'actions': {
'can_borrow': True,
},
'pages': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
},
},
'editor': {
'label': 'Editor (Einfügen/Bearbeiten)',
'actions': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
},
'pages': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'upload_admin': True,
'library_admin': True,
},
},
'manager': {
'label': 'Manager (inkl. Löschen)',
'actions': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
'can_delete': True,
'can_manage_settings': True,
'can_view_logs': True,
},
'pages': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'home_admin': True,
'upload_admin': True,
'library_admin': True,
'admin_borrowings': True,
'library_loans_admin': True,
'admin_damaged_items': True,
'admin_audit_dashboard': True,
'logs': True,
'manage_filters': True,
'manage_locations': True,
},
},
'full_access': {
'label': 'Vollzugriff',
'actions': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
'can_delete': True,
'can_manage_users': True,
'can_manage_settings': True,
'can_view_logs': True,
},
'pages': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'home_admin': True,
'upload_admin': True,
'library_admin': True,
'admin_borrowings': True,
'library_loans_admin': True,
'admin_damaged_items': True,
'admin_audit_dashboard': True,
'logs': True,
'user_del': True,
'register': True,
'manage_filters': True,
'manage_locations': True,
},
},
}
def _normalize_bool_map(source, defaults):
result = dict(defaults)
if isinstance(source, dict):
for key, value in source.items():
result[str(key)] = bool(value)
return result
def get_permission_preset_definitions():
return copy.deepcopy(PERMISSION_PRESETS)
def build_default_permission_payload(preset_key='standard_user'):
selected_key = preset_key if preset_key in PERMISSION_PRESETS else 'standard_user'
preset = PERMISSION_PRESETS.get(selected_key, {})
action_defaults = _normalize_bool_map(preset.get('actions', {}), DEFAULT_ACTION_PERMISSIONS)
page_defaults = _normalize_bool_map(preset.get('pages', {}), DEFAULT_PAGE_PERMISSIONS)
return {
'preset': selected_key,
'actions': action_defaults,
'pages': page_defaults,
}
def get_effective_permissions(username):
user = get_user(username)
if not user:
return build_default_permission_payload('standard_user')
preset_key = user.get('PermissionPreset') or 'standard_user'
payload = build_default_permission_payload(preset_key)
payload['actions'] = _normalize_bool_map(user.get('ActionPermissions', {}), payload['actions'])
payload['pages'] = _normalize_bool_map(user.get('PagePermissions', {}), payload['pages'])
return payload
def update_user_permissions(username, preset_key, action_permissions=None, page_permissions=None):
selected_key = preset_key if preset_key in PERMISSION_PRESETS else 'standard_user'
payload = build_default_permission_payload(selected_key)
if isinstance(action_permissions, dict):
for key, value in action_permissions.items():
payload['actions'][str(key)] = bool(value)
if isinstance(page_permissions, dict):
for key, value in page_permissions.items():
payload['pages'][str(key)] = bool(value)
update_data = {
'PermissionPreset': payload['preset'],
'ActionPermissions': payload['actions'],
'PagePermissions': payload['pages'],
}
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
result = users.update_one({'Username': username}, {'$set': update_data})
if result.matched_count == 0:
result = users.update_one({'username': username}, {'$set': update_data})
client.close()
return result.matched_count > 0
# === FAVORITES MANAGEMENT ===
def get_favorites(username):
"""Return a list of favorite item ObjectId strings for the user."""
@@ -79,7 +327,18 @@ def check_password_strength(password):
Returns:
bool: True if password is strong enough, False otherwise
"""
if len(password) < 6:
if password is None:
return False
if len(password) < 12:
return False
has_lower = any(char.islower() for char in password)
has_upper = any(char.isupper() for char in password)
has_digit = any(char.isdigit() for char in password)
has_symbol = any(not char.isalnum() for char in password)
if not (has_lower and has_upper and has_digit and has_symbol):
return False
return True
@@ -133,14 +392,21 @@ def add_user(username, password, name, last_name, is_student=False, student_card
users = db['users']
if not check_password_strength(password):
return False
permission_defaults = build_default_permission_payload('standard_user')
name_alias = build_name_synonym(name, last_name)
user_doc = {
'Username': username,
'Password': hashing(password),
'Admin': False,
'active_ausleihung': None,
'name': name,
'last_name': last_name,
'IsStudent': bool(is_student)
'name': name_alias,
'last_name': '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
'PagePermissions': permission_defaults['pages'],
}
normalized_card = normalize_student_card_id(student_card_id)
@@ -483,13 +749,14 @@ def update_user_name(username, name, last_name):
bool: True if updated successfully, False otherwise
"""
try:
name_alias = build_name_synonym(name, last_name)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
result = users.update_one(
{'Username': username},
{'$set': {'name': name, 'last_name': last_name}}
{'$set': {'name': name_alias, 'last_name': ''}}
)
client.close()
+20
View File
@@ -24,6 +24,19 @@ services:
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
container_name: inventarsystem-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
app:
build:
context: .
@@ -35,10 +48,16 @@ services:
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_REDIS_HOST: redis
INVENTAR_REDIS_PORT: "6379"
INVENTAR_REDIS_CACHE_DB: "1"
INVENTAR_NOTIFICATION_STATUS_CACHE_TTL: "8"
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
@@ -64,3 +83,4 @@ volumes:
app_backups:
app_logs:
app_deleted_archives:
redis_data:
+1
View File
@@ -8,6 +8,7 @@ apscheduler
python-dateutil
pytz
requests
redis
reportlab
python-barcode
openpyxl