Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09cea7a0f8 | |||
| 061f975727 |
+175
-4
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ apscheduler
|
||||
python-dateutil
|
||||
pytz
|
||||
requests
|
||||
redis
|
||||
reportlab
|
||||
python-barcode
|
||||
openpyxl
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,6 +8,7 @@ apscheduler
|
||||
python-dateutil
|
||||
pytz
|
||||
requests
|
||||
redis
|
||||
reportlab
|
||||
python-barcode
|
||||
openpyxl
|
||||
|
||||
@@ -191,7 +191,29 @@ setup_scheduled_jobs() {
|
||||
fi
|
||||
|
||||
local update_line backup_line
|
||||
update_line="0 3 * * * cd $SCRIPT_DIR && ./update.sh >> $SCRIPT_DIR/logs/update.log 2>&1"
|
||||
local slot_seed slot_hash slot_bucket slot_hour slot_minute
|
||||
local update_window_minutes update_window_hours
|
||||
|
||||
update_window_minutes="${INVENTAR_UPDATE_WINDOW_MINUTES:-120}"
|
||||
if ! [[ "$update_window_minutes" =~ ^[0-9]+$ ]]; then
|
||||
update_window_minutes="120"
|
||||
fi
|
||||
if [ "$update_window_minutes" -lt 15 ]; then
|
||||
update_window_minutes="15"
|
||||
fi
|
||||
|
||||
update_window_hours=$(( (update_window_minutes + 59) / 60 ))
|
||||
if [ "$update_window_hours" -lt 1 ]; then
|
||||
update_window_hours=1
|
||||
fi
|
||||
|
||||
slot_seed="$(cat /etc/machine-id 2>/dev/null || hostname)|${INVENTAR_INSTANCE_ID:-${HOSTNAME:-unknown}}|$SCRIPT_DIR|nightly-update"
|
||||
slot_hash="$(printf '%s' "$slot_seed" | sha256sum | awk '{print $1}')"
|
||||
slot_bucket=$((16#${slot_hash:0:8} % update_window_minutes))
|
||||
slot_hour=$((3 + (slot_bucket / 60)))
|
||||
slot_minute=$((slot_bucket % 60))
|
||||
|
||||
update_line="$slot_minute $slot_hour * * * cd $SCRIPT_DIR && INVENTAR_UPDATE_MODE=auto ./update.sh >> $SCRIPT_DIR/logs/update.log 2>&1"
|
||||
backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1"
|
||||
|
||||
local existing_cron
|
||||
@@ -212,7 +234,7 @@ setup_scheduled_jobs() {
|
||||
fi
|
||||
|
||||
echo "Nightly backup scheduled at 02:30"
|
||||
echo "Nightly auto-update scheduled at 03:00"
|
||||
printf 'Nightly auto-update scheduled at %02d:%02d (deterministic instance slot, %s-minute window)\n' "$slot_hour" "$slot_minute" "$update_window_minutes"
|
||||
}
|
||||
|
||||
ensure_tls_certificates() {
|
||||
|
||||
@@ -15,6 +15,9 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
||||
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
||||
DIST_DIR="$PROJECT_DIR/dist"
|
||||
LOCK_FILE="$PROJECT_DIR/.update.lock"
|
||||
|
||||
DEFAULT_UPDATE_BUFFER_MINUTES="${INVENTAR_UPDATE_BUFFER_MINUTES:-90}"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
||||
@@ -23,6 +26,74 @@ log_message() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
safe_int() {
|
||||
local raw="$1"
|
||||
if [[ "$raw" =~ ^[0-9]+$ ]]; then
|
||||
echo "$raw"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
acquire_update_lock() {
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
log_message "Another update process is already running. Exiting."
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
compute_update_buffer_seconds() {
|
||||
if [ "${INVENTAR_SKIP_UPDATE_BUFFER:-0}" = "1" ]; then
|
||||
echo "0"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Manual interactive runs should not wait by default.
|
||||
if [ -t 1 ] && [ "${INVENTAR_FORCE_UPDATE_BUFFER:-0}" != "1" ]; then
|
||||
echo "0"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local explicit_seconds
|
||||
explicit_seconds="$(safe_int "${INVENTAR_UPDATE_BUFFER_SECONDS:-0}")"
|
||||
if [ "$explicit_seconds" -gt 0 ]; then
|
||||
echo "$explicit_seconds"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local window_minutes window_seconds
|
||||
window_minutes="$(safe_int "$DEFAULT_UPDATE_BUFFER_MINUTES")"
|
||||
if [ "$window_minutes" -le 0 ]; then
|
||||
echo "0"
|
||||
return 0
|
||||
fi
|
||||
|
||||
window_seconds=$((window_minutes * 60))
|
||||
|
||||
local machine_id instance_id seed hash_hex hash_dec
|
||||
machine_id="$(cat /etc/machine-id 2>/dev/null || hostname)"
|
||||
instance_id="${INVENTAR_INSTANCE_ID:-${HOSTNAME:-unknown}}"
|
||||
seed="$machine_id|$instance_id|$PROJECT_DIR|update-buffer"
|
||||
|
||||
hash_hex="$(printf '%s' "$seed" | sha256sum | awk '{print $1}')"
|
||||
hash_dec=$((16#${hash_hex:0:8}))
|
||||
|
||||
echo $((hash_dec % window_seconds))
|
||||
}
|
||||
|
||||
apply_update_buffer_if_needed() {
|
||||
local delay_seconds
|
||||
delay_seconds="$(compute_update_buffer_seconds)"
|
||||
if [ "$delay_seconds" -le 0 ]; then
|
||||
log_message "Update buffer: no delay applied"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_message "Update buffer active: delaying start by ${delay_seconds}s to reduce cross-instance peak load"
|
||||
sleep "$delay_seconds"
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
log_message "ERROR: Required command not found: $1"
|
||||
@@ -395,6 +466,9 @@ verify_stack_health() {
|
||||
}
|
||||
|
||||
main() {
|
||||
acquire_update_lock
|
||||
apply_update_buffer_if_needed
|
||||
|
||||
ensure_runtime_dependencies
|
||||
ensure_tls_certificates
|
||||
ensure_nginx_config_mount_source
|
||||
|
||||
Reference in New Issue
Block a user