Compare commits

..

6 Commits

8 changed files with 504 additions and 45 deletions
+175 -4
View File
@@ -50,6 +50,11 @@ import io
import html
import logging
import secrets
import importlib
try:
redis = importlib.import_module('redis')
except Exception:
redis = None
# QR Code functionality deactivated
# import qrcode
# from qrcode.constants import ERROR_CORRECT_L
@@ -129,6 +134,14 @@ SSL_KEY = cfg.SSL_KEY
LIBRARY_ITEM_TYPES = ['book', 'cd', 'dvd', 'media']
INVOICE_CURRENCY = 'EUR'
NOTIFICATION_STATUS_CACHE_TTL = max(3, int(os.getenv('INVENTAR_NOTIFICATION_STATUS_CACHE_TTL', '8')))
_NOTIFICATION_CACHE_PREFIX = 'inventar:notif'
_NOTIFICATION_REDIS_CLIENT = None
_NOTIFICATION_REDIS_FAILED = False
_NOTIFICATION_LOCAL_CACHE = {}
_NOTIFICATION_LOCAL_VERSIONS = {'admin': 0}
_NOTIFICATION_CACHE_LOCK = threading.Lock()
SCHOOL_PERIODS = cfg.SCHOOL_PERIODS
@@ -551,9 +564,154 @@ def _create_notification(db, *, audience, notif_type, title, message, target_use
'UpdatedAt': now,
}
notifications_col.insert_one(payload)
if audience == 'user' and target_user:
_bump_notification_version(f'user:{target_user}')
elif audience == 'admin':
_bump_notification_version('admin')
return True
def _get_notification_cache_client():
"""Return shared Redis client for distributed notification cache if available."""
global _NOTIFICATION_REDIS_CLIENT, _NOTIFICATION_REDIS_FAILED
if _NOTIFICATION_REDIS_CLIENT is not None:
return _NOTIFICATION_REDIS_CLIENT
if _NOTIFICATION_REDIS_FAILED or redis is None:
return None
try:
redis_host = os.getenv('INVENTAR_REDIS_HOST', 'redis')
redis_port = int(os.getenv('INVENTAR_REDIS_PORT', 6379))
redis_db = int(os.getenv('INVENTAR_REDIS_CACHE_DB', 1))
client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
decode_responses=True,
socket_keepalive=True,
socket_timeout=1,
socket_connect_timeout=1,
)
client.ping()
_NOTIFICATION_REDIS_CLIENT = client
app.logger.info(f'Notification cache backend enabled: {redis_host}:{redis_port}/db{redis_db}')
return _NOTIFICATION_REDIS_CLIENT
except Exception as exc:
_NOTIFICATION_REDIS_FAILED = True
app.logger.warning(f'Notification cache backend unavailable, using local cache fallback: {exc}')
return None
def _notification_scope_key(scope):
return f'{_NOTIFICATION_CACHE_PREFIX}:ver:{scope}'
def _notification_status_key(username, is_admin, version_tag):
role = 'admin' if is_admin else 'user'
return f'{_NOTIFICATION_CACHE_PREFIX}:status:{role}:{username}:{version_tag}'
def _get_notification_version_tag(username, is_admin=False):
user_scope = f'user:{username}'
cache_client = _get_notification_cache_client()
if cache_client:
user_version = cache_client.get(_notification_scope_key(user_scope)) or '0'
if is_admin:
admin_version = cache_client.get(_notification_scope_key('admin')) or '0'
return f'u{user_version}-a{admin_version}'
return f'u{user_version}'
with _NOTIFICATION_CACHE_LOCK:
user_version = _NOTIFICATION_LOCAL_VERSIONS.get(user_scope, 0)
if is_admin:
admin_version = _NOTIFICATION_LOCAL_VERSIONS.get('admin', 0)
return f'u{user_version}-a{admin_version}'
return f'u{user_version}'
def _bump_notification_version(scope):
cache_client = _get_notification_cache_client()
if cache_client:
try:
cache_client.incr(_notification_scope_key(scope))
return
except Exception as exc:
app.logger.warning(f'Could not bump notification cache version for {scope}: {exc}')
with _NOTIFICATION_CACHE_LOCK:
current = int(_NOTIFICATION_LOCAL_VERSIONS.get(scope, 0))
_NOTIFICATION_LOCAL_VERSIONS[scope] = current + 1
# Prevent stale local payload reuse after version bump.
_NOTIFICATION_LOCAL_CACHE.clear()
def _get_cached_unread_status(username, is_admin=False):
version_tag = _get_notification_version_tag(username, is_admin=is_admin)
key = _notification_status_key(username, is_admin, version_tag)
cache_client = _get_notification_cache_client()
if cache_client:
try:
cached = cache_client.get(key)
if cached:
return json.loads(cached), version_tag
except Exception as exc:
app.logger.warning(f'Could not read notification status cache for {username}: {exc}')
return None, version_tag
now = time.time()
with _NOTIFICATION_CACHE_LOCK:
cached = _NOTIFICATION_LOCAL_CACHE.get(key)
if not cached:
return None, version_tag
if cached.get('expires_at', 0) <= now:
_NOTIFICATION_LOCAL_CACHE.pop(key, None)
return None, version_tag
return cached.get('payload'), version_tag
def _set_cached_unread_status(username, is_admin, version_tag, payload):
key = _notification_status_key(username, is_admin, version_tag)
cache_client = _get_notification_cache_client()
if cache_client:
try:
cache_client.setex(key, NOTIFICATION_STATUS_CACHE_TTL, json.dumps(payload, default=str))
return
except Exception as exc:
app.logger.warning(f'Could not write notification status cache for {username}: {exc}')
with _NOTIFICATION_CACHE_LOCK:
_NOTIFICATION_LOCAL_CACHE[key] = {
'expires_at': time.time() + NOTIFICATION_STATUS_CACHE_TTL,
'payload': payload,
}
def _build_unread_status_etag(version_tag, payload):
unread_count = int(payload.get('unread_count', 0))
latest = payload.get('latest_unread') or {}
latest_created = latest.get('created_at') or ''
latest_type = latest.get('type') or ''
return f'W/"notif-{version_tag}-{unread_count}-{latest_type}-{latest_created}"'
def _build_cached_json_response(payload, etag_value):
incoming_etag = (request.headers.get('If-None-Match') or '').strip()
if incoming_etag and incoming_etag == etag_value:
response = make_response('', 304)
else:
response = jsonify(payload)
response.headers['Cache-Control'] = f'private, max-age={NOTIFICATION_STATUS_CACHE_TTL}, must-revalidate'
response.headers['ETag'] = etag_value
response.headers['Vary'] = 'Cookie'
return response
def _get_notifications_for_user(db, username, is_admin=False, limit=150):
"""Fetch notifications visible to the current user, newest first."""
query = {
@@ -8284,13 +8442,15 @@ def mark_notification_read(notification_id):
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
db['notifications'].update_one(
result = db['notifications'].update_one(
{'_id': ObjectId(notification_id)},
{
'$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()}
}
)
if result.modified_count > 0:
_bump_notification_version(f'user:{username}')
except Exception as exc:
app.logger.warning(f"Could not mark notification as read {notification_id}: {exc}")
finally:
@@ -8325,13 +8485,15 @@ def mark_all_notifications_read():
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
db['notifications'].update_many(
result = db['notifications'].update_many(
query,
{
'$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()}
}
)
if result.modified_count > 0:
_bump_notification_version(f'user:{username}')
except Exception as exc:
app.logger.warning(f"Could not mark all notifications as read for {username}: {exc}")
finally:
@@ -8354,6 +8516,11 @@ def notifications_unread_status():
except Exception:
is_admin_user = False
cached_payload, version_tag = _get_cached_unread_status(username, is_admin=is_admin_user)
if cached_payload is not None:
cached_etag = _build_unread_status_etag(version_tag, cached_payload)
return _build_cached_json_response(cached_payload, cached_etag)
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
@@ -8397,11 +8564,15 @@ def notifications_unread_status():
'severity': latest_unread.get('Severity', 'info'),
}
return jsonify({
payload = {
'ok': True,
'unread_count': unread_count,
'latest_unread': latest_payload,
})
}
_set_cached_unread_status(username, is_admin_user, version_tag, payload)
etag_value = _build_unread_status_etag(version_tag, payload)
return _build_cached_json_response(payload, etag_value)
except Exception as exc:
app.logger.warning(f"Could not fetch unread notification status for {username}: {exc}")
return jsonify({'ok': False, 'error': 'status_fetch_failed'}), 500
+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;
}
}
+194 -5
View File
@@ -136,6 +136,10 @@
top: 0;
z-index: 1900;
}
.navbar .container-fluid {
gap: 10px;
}
.navbar-brand {
font-weight: bold;
@@ -538,22 +542,200 @@
}
@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%;
}
.navbar-mobile-arranged .navbar-collapse {
display: block;
}
.navbar-mobile-arranged .navbar-nav {
flex-direction: column;
}
body.nav-mobile-tight .function-search-form {
flex-direction: column;
}
body.nav-mobile-tight .function-search-input,
body.nav-mobile-tight .function-search-btn,
body.nav-mobile-tight .user-menu-btn,
body.nav-mobile-tight .module-selector-bar .module-tab {
width: 100%;
}
body.nav-mobile-tight .module-selector-bar .module-tabs {
flex-direction: column;
}
body.nav-mobile-tight .navbar-text {
font-size: 0.88rem;
}
}
</style>
@@ -1286,6 +1468,13 @@
if (!navRoot || !navContainer) return;
const width = navContainer.clientWidth || window.innerWidth;
navRoot.classList.remove('nav-compact', 'nav-ultra-compact');
navRoot.classList.toggle('nav-mobile-arranged', window.innerWidth < 992);
navRoot.classList.toggle('nav-mobile-tight', window.innerWidth < 520);
if (document.body) {
document.body.classList.toggle('nav-mobile-arranged', window.innerWidth < 992);
document.body.classList.toggle('nav-mobile-tight', window.innerWidth < 520);
}
if (window.innerWidth < 992) {
return;
+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 */
+61 -21
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;
}
@@ -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);
+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