Feature: Notification support + Enhanced request management (#618)

- Added notification support via Apprise dependency
- Notifications can be configured globally or per user, with full
customization of events and notification type.
- Added expanded ActivityCard for increased detail of each request, file
info, and managing the attached file.
- Enhanced tests
This commit is contained in:
Alex
2026-02-15 17:59:53 +00:00
committed by GitHub
parent b7bee132a1
commit 1931eb96a5
63 changed files with 4305 additions and 683 deletions
+6 -2
View File
@@ -164,7 +164,9 @@ make_writable() {
# Fix any misowned subdirectories/files (e.g., from previous runs as root)
if [ "$did_full_chown" -eq 0 ] && [ -d "$folder" ]; then
echo "Checking for misowned files/directories in $folder"
find "$folder" -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
# Stay on the same filesystem to avoid traversing mounted subpaths
# (for example read-only bind mounts under /app in dev setups).
find "$folder" -xdev -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
-exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true
fi
test_write $folder || echo "Failed to test write to ${folder}, continuing..."
@@ -174,7 +176,9 @@ fix_misowned() {
folder=$1
mkdir -p $folder
echo "Checking for misowned files/directories in $folder"
find "$folder" \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
# Stay on the same filesystem to avoid traversing mounted subpaths
# (for example read-only bind mounts under /app in dev setups).
find "$folder" -xdev \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
-exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true
}
+1
View File
@@ -15,3 +15,4 @@ rarfile
qbittorrent-api
transmission-rpc
authlib>=1.6.6,<1.7
apprise>=1.9.0
+48 -39
View File
@@ -128,63 +128,72 @@ def _should_extract_cookie(name: str, extract_all: bool) -> bool:
return is_cf or is_ddg
def _store_extracted_cookies(
*,
url: str,
cookies: list[Any],
user_agent: Optional[str] = None,
) -> None:
"""Store filtered bypass cookies (and optional UA) for a URL domain."""
parsed = urlparse(url)
domain = parsed.hostname or ""
if not domain:
return
base_domain = _get_base_domain(domain)
extract_all = base_domain in FULL_COOKIE_DOMAINS
cookies_found: dict[str, dict[str, Any]] = {}
for cookie in cookies:
name = getattr(cookie, "name", "") or ""
if not _should_extract_cookie(name, extract_all):
continue
expires = getattr(cookie, "expires", None)
if expires is not None and expires <= 0:
expires = None
cookies_found[name] = {
"value": getattr(cookie, "value", ""),
"domain": getattr(cookie, "domain", None) or domain,
"path": getattr(cookie, "path", None) or "/",
"expiry": expires,
"secure": bool(getattr(cookie, "secure", True)),
"httpOnly": True,
}
if not cookies_found:
return
with _cf_cookies_lock:
_cf_cookies[base_domain] = cookies_found
if user_agent:
_cf_user_agents[base_domain] = user_agent
logger.debug(f"Stored UA for {base_domain}: {str(user_agent)[:60]}...")
else:
logger.debug(f"No UA captured for {base_domain}")
cookie_type = "all" if extract_all else "protection"
logger.debug(f"Extracted {len(cookies_found)} {cookie_type} cookies for {base_domain}")
async def _extract_cookies_from_cdp(driver, page, url: str) -> None:
"""Extract cookies from a CDP browser after successful bypass."""
try:
parsed = urlparse(url)
domain = parsed.hostname or ""
if not domain:
return
base_domain = _get_base_domain(domain)
extract_all = base_domain in FULL_COOKIE_DOMAINS
try:
all_cookies = await driver.cookies.get_all(requests_cookie_format=True)
except Exception as e:
logger.debug(f"Failed to get cookies via CDP: {e}")
return
cookies_found = {}
for cookie in all_cookies:
name = getattr(cookie, "name", "") or ""
if not _should_extract_cookie(name, extract_all):
continue
expires = getattr(cookie, "expires", None)
if expires is not None and expires <= 0:
expires = None
cookies_found[name] = {
"value": getattr(cookie, "value", ""),
"domain": getattr(cookie, "domain", None) or domain,
"path": getattr(cookie, "path", None) or "/",
"expiry": expires,
"secure": bool(getattr(cookie, "secure", True)),
"httpOnly": True,
}
if not cookies_found:
return
try:
user_agent = await page.evaluate("navigator.userAgent")
except Exception:
user_agent = None
with _cf_cookies_lock:
_cf_cookies[base_domain] = cookies_found
if user_agent:
_cf_user_agents[base_domain] = user_agent
logger.debug(f"Stored UA for {base_domain}: {str(user_agent)[:60]}...")
else:
logger.debug(f"No UA captured for {base_domain}")
cookie_type = "all" if extract_all else "protection"
logger.debug(f"Extracted {len(cookies_found)} {cookie_type} cookies for {base_domain}")
_store_extracted_cookies(url=url, cookies=all_cookies, user_agent=user_agent)
except Exception as e:
logger.debug(f"Failed to extract cookies: {e}")
def get_cf_cookies_for_domain(domain: str) -> dict[str, str]:
"""Get stored cookies for a domain. Returns empty dict if none available."""
if not domain:
+337
View File
@@ -0,0 +1,337 @@
"""Notifications settings tab registration."""
from __future__ import annotations
import re
from typing import Any
from urllib.parse import urlsplit
from shelfmark.core.notifications import NotificationEvent, send_test_notification
from shelfmark.core.settings_registry import (
ActionButton,
HeadingField,
TableField,
load_config_file,
register_on_save,
register_settings,
)
_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*$")
_ROUTE_EVENT_ALL = "all"
_ADMIN_EVENT_OPTIONS = [
{"value": NotificationEvent.REQUEST_CREATED.value, "label": "New request submitted"},
{"value": NotificationEvent.REQUEST_FULFILLED.value, "label": "Request approved"},
{"value": NotificationEvent.REQUEST_REJECTED.value, "label": "Request rejected"},
{"value": NotificationEvent.DOWNLOAD_COMPLETE.value, "label": "Download complete"},
{"value": NotificationEvent.DOWNLOAD_FAILED.value, "label": "Download failed"},
]
_ROUTE_EVENT_OPTIONS = [
{"value": _ROUTE_EVENT_ALL, "label": "All"},
*_ADMIN_EVENT_OPTIONS,
]
_ROUTE_EVENT_ORDER = [option["value"] for option in _ROUTE_EVENT_OPTIONS]
_ROUTE_EVENT_INDEX = {event: index for index, event in enumerate(_ROUTE_EVENT_ORDER)}
_ALLOWED_ROUTE_EVENTS = set(_ROUTE_EVENT_ORDER)
_DEFAULT_ROUTE_ROWS = [{"event": [_ROUTE_EVENT_ALL], "url": ""}]
def _looks_like_apprise_url(url: str) -> bool:
split = urlsplit(url)
if not split.scheme:
return False
if not _URL_SCHEME_RE.match(split.scheme):
return False
return " " not in url
def _coerce_route_rows(value: Any) -> list[dict[str, Any]]:
if value is None:
return []
if isinstance(value, list):
return [row for row in value if isinstance(row, dict)]
if isinstance(value, dict):
return [value]
return []
def _coerce_route_event_values(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if isinstance(value, (tuple, set)):
return list(value)
return [value]
def _normalize_route_events(value: Any) -> list[str]:
normalized: list[str] = []
seen: set[str] = set()
for raw_event in _coerce_route_event_values(value):
event = str(raw_event or "").strip().lower()
if not event or event not in _ALLOWED_ROUTE_EVENTS:
continue
if event in seen:
continue
seen.add(event)
normalized.append(event)
if _ROUTE_EVENT_ALL in seen:
return [_ROUTE_EVENT_ALL]
return sorted(normalized, key=lambda event: _ROUTE_EVENT_INDEX[event])
def _normalize_routes(value: Any) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
seen: set[tuple[tuple[str, ...], str]] = set()
for row in _coerce_route_rows(value):
events = _normalize_route_events(row.get("event"))
if not events:
continue
url = str(row.get("url") or "").strip()
key = (tuple(events), url)
if key in seen:
continue
seen.add(key)
normalized.append({"event": events, "url": url})
return normalized
def _count_invalid_route_events(value: Any) -> int:
invalid = 0
for row in _coerce_route_rows(value):
raw_events = _coerce_route_event_values(row.get("event"))
if not raw_events:
invalid += 1
continue
for raw_event in raw_events:
event = str(raw_event or "").strip().lower()
if not event or event not in _ALLOWED_ROUTE_EVENTS:
invalid += 1
return invalid
def _count_invalid_route_urls(routes: list[dict[str, Any]]) -> int:
return sum(1 for row in routes if row["url"] and not _looks_like_apprise_url(row["url"]))
def _ensure_default_route_row(routes: list[dict[str, Any]]) -> list[dict[str, Any]]:
return routes if routes else [dict(row) for row in _DEFAULT_ROUTE_ROWS]
def _extract_unique_route_urls(routes: list[dict[str, Any]]) -> list[str]:
urls: list[str] = []
seen: set[str] = set()
for row in routes:
url = row.get("url", "")
if not url:
continue
if url in seen:
continue
seen.add(url)
urls.append(url)
return urls
def build_notification_test_result(routes_input: Any, *, scope_label: str) -> dict[str, Any]:
invalid_event_count = _count_invalid_route_events(routes_input)
if invalid_event_count:
return {
"success": False,
"message": (
f"Found {invalid_event_count} invalid {scope_label} notification route event value(s). "
"Fix route events before running a test."
),
}
normalized_routes = _normalize_routes(routes_input)
invalid_url_count = _count_invalid_route_urls(normalized_routes)
if invalid_url_count:
return {
"success": False,
"message": (
f"Found {invalid_url_count} invalid {scope_label} notification URL(s). "
"Fix route URLs before running a test."
),
}
urls = _extract_unique_route_urls(normalized_routes)
if not urls:
return {
"success": False,
"message": f"Add at least one {scope_label} notification URL route first.",
}
return send_test_notification(urls)
def normalize_notification_routes(value: Any) -> list[dict[str, Any]]:
"""Normalize route table rows for notification preferences."""
return _normalize_routes(value)
def is_valid_notification_url(url: str) -> bool:
"""Shared URL validation for notifications preferences."""
return _looks_like_apprise_url(url)
def _on_save_notifications(values: dict[str, Any]) -> dict[str, Any]:
existing = load_config_file("notifications")
effective: dict[str, Any] = dict(existing)
effective.update(values)
admin_routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", [])
invalid_admin_event_count = _count_invalid_route_events(admin_routes_input)
if invalid_admin_event_count:
return {
"error": True,
"message": (
f"Found {invalid_admin_event_count} invalid global notification route event value(s)."
),
"values": values,
}
normalized_admin_routes = _normalize_routes(admin_routes_input)
invalid_admin_url_count = _count_invalid_route_urls(normalized_admin_routes)
if invalid_admin_url_count:
return {
"error": True,
"message": (
f"Found {invalid_admin_url_count} invalid global notification URL(s). "
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
),
"values": values,
}
user_routes_input = effective.get("USER_NOTIFICATION_ROUTES", [])
invalid_user_event_count = _count_invalid_route_events(user_routes_input)
if invalid_user_event_count:
return {
"error": True,
"message": (
f"Found {invalid_user_event_count} invalid personal notification route event value(s)."
),
"values": values,
}
normalized_user_routes = _normalize_routes(user_routes_input)
invalid_user_url_count = _count_invalid_route_urls(normalized_user_routes)
if invalid_user_url_count:
return {
"error": True,
"message": (
f"Found {invalid_user_url_count} invalid personal notification URL(s). "
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
),
"values": values,
}
admin_routes_touched = "ADMIN_NOTIFICATION_ROUTES" in values
if admin_routes_touched:
values["ADMIN_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_admin_routes)
user_routes_touched = "USER_NOTIFICATION_ROUTES" in values
if user_routes_touched:
values["USER_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_user_routes)
return {"error": False, "values": values}
def _test_admin_notification_action(current_values: dict[str, Any]) -> dict[str, Any]:
persisted = load_config_file("notifications")
effective: dict[str, Any] = dict(persisted)
if isinstance(current_values, dict):
effective.update(current_values)
routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", [])
return build_notification_test_result(routes_input, scope_label="global")
register_on_save("notifications", _on_save_notifications)
@register_settings("notifications", "Notifications", icon="bell", order=7)
def notifications_settings():
"""Global notifications settings."""
return [
HeadingField(
key="notifications_heading",
title="Global Notifications",
description=(
"Global notifications send selected events for all users to configured routes. "
"Users can manage personal notifications in User Preferences."
),
),
TableField(
key="ADMIN_NOTIFICATION_ROUTES",
label="",
description=(
"Create one route per URL. Start with All, then add event-specific routes "
"for targeted delivery. Need format examples? "
"[View Apprise URL formats](https://appriseit.com/services/)."
),
columns=[
{
"key": "event",
"label": "Event",
"type": "multiselect",
"options": _ROUTE_EVENT_OPTIONS,
"defaultValue": [_ROUTE_EVENT_ALL],
"placeholder": "Select events...",
},
{
"key": "url",
"label": "Notification URL",
"type": "text",
"placeholder": "e.g. ntfys://ntfy.sh/shelfmark",
},
],
default=[dict(row) for row in _DEFAULT_ROUTE_ROWS],
add_label="Add Route",
empty_message="No routes configured.",
),
ActionButton(
key="test_admin_notification",
label="Test Notification",
description="Send a test notification to all configured global route URLs.",
style="primary",
callback=_test_admin_notification_action,
),
TableField(
key="USER_NOTIFICATION_ROUTES",
label="",
description=(
"Create one route per URL. Start with All, then add event-specific routes "
"for targeted delivery. Need format examples? "
"[View Apprise URL formats](https://appriseit.com/services/)."
),
columns=[
{
"key": "event",
"label": "Event",
"type": "multiselect",
"options": _ROUTE_EVENT_OPTIONS,
"defaultValue": [_ROUTE_EVENT_ALL],
"placeholder": "Select events...",
},
{
"key": "url",
"label": "Notification URL",
"type": "text",
"placeholder": "e.g. ntfys://ntfy.sh/username-topic",
},
],
default=[dict(row) for row in _DEFAULT_ROUTE_ROWS],
add_label="Add Route",
empty_message="No routes configured.",
user_overridable=True,
hidden_in_ui=True,
),
]
+21 -3
View File
@@ -3,6 +3,7 @@
import os
from typing import Any, Callable
from shelfmark.core.utils import normalize_http_url
from shelfmark.core.user_db import UserDB
@@ -20,10 +21,27 @@ def on_save_security(
values: dict[str, Any],
) -> dict[str, Any]:
"""Validate security values before persistence."""
if values.get("AUTH_METHOD") == "oidc" and not _has_local_password_admin():
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": values}
normalized_values = values.copy()
return {"error": False, "values": values}
discovery_url = normalized_values.get("OIDC_DISCOVERY_URL")
if discovery_url is not None:
normalized_values["OIDC_DISCOVERY_URL"] = normalize_http_url(
str(discovery_url),
default_scheme="https",
)
proxy_logout_url = normalized_values.get("PROXY_AUTH_LOGOUT_URL")
if proxy_logout_url is not None:
normalized_values["PROXY_AUTH_LOGOUT_URL"] = normalize_http_url(
str(proxy_logout_url),
default_scheme="https",
strip_trailing_slash=False,
)
if normalized_values.get("AUTH_METHOD") == "oidc" and not _has_local_password_admin():
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values}
return {"error": False, "values": normalized_values}
def test_oidc_connection(
+74 -60
View File
@@ -4,30 +4,21 @@ from typing import Any, Callable
from flask import Flask, jsonify, request
from shelfmark.config.notifications_settings import (
build_notification_test_result,
is_valid_notification_url,
normalize_notification_routes,
)
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_settings_overrides import (
build_user_preferences_payload as _build_user_preferences_payload,
get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields,
get_settings_registry as _get_settings_registry,
)
from shelfmark.core.user_db import UserDB
from shelfmark.core.request_policy import parse_policy_mode, validate_policy_rules
def _get_settings_registry():
# Ensure settings modules are loaded before reading registry metadata.
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
import shelfmark.config.users_settings # noqa: F401
from shelfmark.core import settings_registry
return settings_registry
def _get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]:
settings_registry = _get_settings_registry()
tab = settings_registry.get_settings_tab(tab_name)
if not tab:
return []
overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name)
return [(field.key, field) for field in tab.fields if field.key in overridable_map]
def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
settings_registry = _get_settings_registry()
field_map = settings_registry.get_settings_field_map()
@@ -59,11 +50,48 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li
valid[key] = normalized_rules
continue
if key == "USER_NOTIFICATION_ROUTES":
normalized_routes = normalize_notification_routes(value)
invalid_count = sum(
1
for row in normalized_routes
if row.get("url") and not is_valid_notification_url(str(row.get("url")))
)
if invalid_count:
errors.append(
(
f"Invalid value for {key}: found {invalid_count} invalid URL(s). "
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
)
)
continue
valid[key] = normalized_routes
continue
valid[key] = value
return valid, errors
def build_user_notification_test_response(
*,
user_id: int,
payload: Any,
) -> tuple[dict[str, Any], int]:
from shelfmark.core.config import config as app_config
routes_input = app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=user_id)
if isinstance(payload, dict):
if "USER_NOTIFICATION_ROUTES" in payload:
routes_input = payload.get("USER_NOTIFICATION_ROUTES")
elif "routes" in payload:
routes_input = payload.get("routes")
result = build_notification_test_result(routes_input, scope_label="personal")
status_code = 200 if result.get("success", False) else 400
return result, status_code
def register_admin_settings_routes(
app: Flask,
user_db: UserDB,
@@ -101,54 +129,40 @@ def register_admin_settings_routes(
if not user:
return jsonify({"error": "User not found"}), 404
from shelfmark.core import settings_registry
from shelfmark.core.config import config as app_config
ordered_fields = _get_ordered_user_overridable_fields("downloads")
if not ordered_fields:
try:
payload = _build_user_preferences_payload(user_db, user_id, "downloads")
except ValueError:
return jsonify({"error": "Downloads settings tab not found"}), 500
download_config = load_config_file("downloads")
user_settings = user_db.get_user_settings(user_id)
ordered_keys = [key for key, _ in ordered_fields]
return jsonify(payload)
fields_payload: list[dict[str, Any]] = []
global_values: dict[str, Any] = {}
effective: dict[str, dict[str, Any]] = {}
@app.route("/api/admin/users/<int:user_id>/notification-preferences", methods=["GET"])
@require_admin
def admin_get_notification_preferences(user_id):
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
for key, field in ordered_fields:
serialized = settings_registry.serialize_field(field, "downloads", include_value=False)
serialized["fromEnv"] = bool(field.env_supported and settings_registry.is_value_from_env(field))
fields_payload.append(serialized)
try:
payload = _build_user_preferences_payload(user_db, user_id, "notifications")
except ValueError:
return jsonify({"error": "Notifications settings tab not found"}), 500
global_values[key] = app_config.get(key, field.default)
return jsonify(payload)
source = "default"
value = app_config.get(key, field.default, user_id=user_id)
if field.env_supported and settings_registry.is_value_from_env(field):
source = "env_var"
elif key in user_settings and user_settings[key] is not None:
source = "user_override"
value = user_settings[key]
elif key in download_config:
source = "global_config"
@app.route("/api/admin/users/<int:user_id>/notification-preferences/test", methods=["POST"])
@require_admin
def admin_test_notification_preferences(user_id):
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
effective[key] = {"value": value, "source": source}
user_overrides = {
key: user_settings[key]
for key in ordered_keys
if key in user_settings and user_settings[key] is not None
}
return jsonify({
"tab": "downloads",
"keys": ordered_keys,
"fields": fields_payload,
"globalValues": global_values,
"userOverrides": user_overrides,
"effective": effective,
})
payload = request.get_json(silent=True)
result, status_code = build_user_notification_test_response(
user_id=user_id,
payload=payload,
)
return jsonify(result), status_code
@app.route("/api/admin/settings/overrides-summary", methods=["GET"])
@require_admin
+1
View File
@@ -84,6 +84,7 @@ class Config:
# This handles cases where config is accessed before settings are registered
try:
import shelfmark.config.settings # noqa: F401 - main app settings
import shelfmark.config.notifications_settings # noqa: F401 - notifications settings
import shelfmark.release_sources # noqa: F401 - plugin settings
import shelfmark.metadata_providers # noqa: F401 - plugin settings
except ImportError:
+66 -29
View File
@@ -12,7 +12,16 @@ logger = setup_logger(__name__)
# Known variable tokens, sorted longest-first to avoid partial matches
# e.g., "SeriesPosition" must match before "Series"
KNOWN_TOKENS = ['seriesposition', 'partnumber', 'subtitle', 'author', 'series', 'title', 'year', 'user']
KNOWN_TOKENS = [
'seriesposition',
'partnumber',
'subtitle',
'author',
'series',
'title',
'year',
'user',
]
# Match any {...} block for template parsing
BRACE_PATTERN = re.compile(r'\{([^}]+)\}')
@@ -89,46 +98,74 @@ def parse_naming_template(
# Normalize metadata keys to lowercase for case-insensitive matching
normalized = {k.lower(): v for k, v in metadata.items()}
def replace_block(match: re.Match) -> str:
content = match.group(1)
def find_token(content: str) -> tuple[Optional[str], int]:
content_lower = content.lower()
# Find which known token appears in this block (longest first)
for token in KNOWN_TOKENS:
idx = content_lower.find(token)
if idx != -1:
prefix = content[:idx]
suffix = content[idx + len(token):]
return token, idx
return None, -1
# Get the value for this token
value = normalized.get(token)
def token_value(token: str) -> str:
value = normalized.get(token)
if token == 'seriesposition':
value = format_series_position(value)
if value is None:
return ""
return str(value).strip()
# Special handling for series position
if token == 'seriesposition':
value = format_series_position(value)
def render_block(content: str) -> Optional[str]:
token, idx = find_token(content)
if token is None:
return None
# Convert to string
if value is None:
value = ""
else:
value = str(value).strip()
prefix = content[:idx]
suffix = content[idx + len(token):]
value = token_value(token)
if not value:
return ""
# If value is empty, return empty string (no prefix/suffix)
if not value:
return ""
if not allow_path_separators:
value = value.replace("/", "_")
value = sanitize_filename(value)
return f"{prefix}{value}{suffix}"
if not allow_path_separators:
value = value.replace("/", "_")
# Sanitize the value
value = sanitize_filename(value)
# Process brace blocks in order so we can support conditional literal blocks like:
# { - Part }{PartNumber}
matches = list(BRACE_PATTERN.finditer(template))
if not matches:
result = template
else:
parts: list[str] = []
cursor = 0
for idx, match in enumerate(matches):
parts.append(template[cursor:match.start()])
content = match.group(1)
rendered = render_block(content)
return f"{prefix}{value}{suffix}"
if rendered is not None:
parts.append(rendered)
else:
conditional_literal = False
include_literal = False
if idx + 1 < len(matches) and match.end() == matches[idx + 1].start():
next_content = matches[idx + 1].group(1)
next_token, _next_idx = find_token(next_content)
if next_token is not None:
conditional_literal = True
include_literal = bool(token_value(next_token))
if include_literal:
parts.append(content)
elif not conditional_literal:
# Preserve blocks that look like literal text, but treat bare unknown
# placeholders as missing variables.
if re.search(r"\s", content):
parts.append(match.group(0))
# No known token found → return original block unchanged
return match.group(0)
cursor = match.end()
# Replace all tokens
result = BRACE_PATTERN.sub(replace_block, template)
parts.append(template[cursor:])
result = "".join(parts)
# Clean up any double slashes that might result from empty tokens
result = re.sub(r'/+', '/', result)
+380
View File
@@ -0,0 +1,380 @@
"""Apprise notification dispatch for global and per-user events."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from enum import Enum
from typing import Any, Iterable
try:
import apprise
except Exception: # pragma: no cover - exercised in tests via monkeypatch
apprise = None # type: ignore[assignment]
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
# Small pool for non-blocking dispatch. Notification sends are I/O bound and infrequent.
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="Notify")
_ROUTE_EVENT_ALL = "all"
_APPRISE_APP_ID = "Shelfmark"
_APPRISE_APP_DESC = "Shelfmark notifications"
_APPRISE_LOGO_URL = (
"https://raw.githubusercontent.com/calibrain/shelfmark/main/src/frontend/public/logo.png"
)
class NotificationEvent(str, Enum):
"""Global notification event identifiers."""
REQUEST_CREATED = "request_created"
REQUEST_FULFILLED = "request_fulfilled"
REQUEST_REJECTED = "request_rejected"
DOWNLOAD_COMPLETE = "download_complete"
DOWNLOAD_FAILED = "download_failed"
@dataclass
class NotificationContext:
"""Context used to render notification templates."""
event: NotificationEvent
title: str
author: str
username: str | None = None
content_type: str | None = None
format: str | None = None
source: str | None = None
admin_note: str | None = None
error_message: str | None = None
def _normalize_urls(value: Any) -> list[str]:
if value is None:
return []
raw_values: list[Any]
if isinstance(value, list):
raw_values = value
elif isinstance(value, str):
# Support legacy/manual configs.
raw_values = [segment for part in value.splitlines() for segment in part.split(",")]
else:
raw_values = [value]
normalized: list[str] = []
seen: set[str] = set()
for raw_url in raw_values:
url = str(raw_url or "").strip()
if not url:
continue
if url in seen:
continue
seen.add(url)
normalized.append(url)
return normalized
def _normalize_routes(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
allowed_events = {_ROUTE_EVENT_ALL, *(event.value for event in NotificationEvent)}
normalized: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for row in value:
if not isinstance(row, dict):
continue
raw_events = row.get("event")
if isinstance(raw_events, list):
event_values = raw_events
elif isinstance(raw_events, (tuple, set)):
event_values = list(raw_events)
else:
event_values = [raw_events]
url = str(row.get("url") or "").strip()
if not url:
continue
row_events: list[str] = []
for raw_event in event_values:
event = str(raw_event or "").strip().lower()
if event not in allowed_events:
continue
if event in row_events:
continue
row_events.append(event)
if _ROUTE_EVENT_ALL in row_events:
row_events = [_ROUTE_EVENT_ALL]
for event in row_events:
key = (event, url)
if key in seen:
continue
seen.add(key)
normalized.append({"event": event, "url": url})
return normalized
def _resolve_admin_routes() -> list[dict[str, str]]:
return _normalize_routes(app_config.get("ADMIN_NOTIFICATION_ROUTES", []))
def _normalize_user_id(value: Any) -> int | None:
try:
user_id = int(value)
except (TypeError, ValueError):
return None
if user_id < 1:
return None
return user_id
def _resolve_user_routes(user_id: int | None) -> list[dict[str, str]]:
normalized_user_id = _normalize_user_id(user_id)
if normalized_user_id is None:
return []
return _normalize_routes(
app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=normalized_user_id)
)
def _resolve_route_urls_for_event(
routes: list[dict[str, str]],
event: NotificationEvent,
) -> list[str]:
selected: list[str] = []
seen: set[str] = set()
event_value = event.value
for row in routes:
row_event = row.get("event", "")
if row_event not in {_ROUTE_EVENT_ALL, event_value}:
continue
url = row.get("url", "")
if not url or url in seen:
continue
seen.add(url)
selected.append(url)
return selected
def _resolve_notify_type(event: NotificationEvent) -> Any:
if apprise is None:
fallback = {
NotificationEvent.REQUEST_CREATED: "info",
NotificationEvent.REQUEST_FULFILLED: "success",
NotificationEvent.REQUEST_REJECTED: "warning",
NotificationEvent.DOWNLOAD_COMPLETE: "success",
NotificationEvent.DOWNLOAD_FAILED: "failure",
}
return fallback[event]
mapping = {
NotificationEvent.REQUEST_CREATED: apprise.NotifyType.INFO,
NotificationEvent.REQUEST_FULFILLED: apprise.NotifyType.SUCCESS,
NotificationEvent.REQUEST_REJECTED: apprise.NotifyType.WARNING,
NotificationEvent.DOWNLOAD_COMPLETE: apprise.NotifyType.SUCCESS,
NotificationEvent.DOWNLOAD_FAILED: apprise.NotifyType.FAILURE,
}
return mapping[event]
def _clean_text(value: Any, fallback: str) -> str:
text = str(value or "").strip()
return text or fallback
def _render_message(context: NotificationContext) -> tuple[str, str]:
event = context.event
title = _clean_text(context.title, "Unknown title")
author = _clean_text(context.author, "Unknown author")
username = _clean_text(context.username, "A user")
if event == NotificationEvent.REQUEST_CREATED:
return "New Request", f'{username} requested "{title}" by {author}'
if event == NotificationEvent.REQUEST_FULFILLED:
return "Request Approved", f'Request for "{title}" by {author} was approved.'
if event == NotificationEvent.REQUEST_REJECTED:
note = _clean_text(context.admin_note, "")
note_line = f"\nNote: {note}" if note else ""
return "Request Rejected", f'Request for "{title}" by {author} was rejected.{note_line}'
if event == NotificationEvent.DOWNLOAD_COMPLETE:
return "Download Complete", f'"{title}" by {author} downloaded successfully.'
error_message = _clean_text(context.error_message, "")
error_line = f"\nError: {error_message}" if error_message else ""
return "Download Failed", f'Failed to download "{title}" by {author}.{error_line}'
def _dispatch_to_apprise(
urls: Iterable[str],
*,
title: str,
body: str,
notify_type: Any,
) -> dict[str, Any]:
normalized_urls = _normalize_urls(list(urls))
if not normalized_urls:
return {"success": False, "message": "No notification URLs configured"}
if apprise is None:
return {"success": False, "message": "Apprise is not installed"}
apobj = _create_apprise_client()
if apobj is None:
return {"success": False, "message": "Apprise is not installed"}
valid_urls = 0
invalid_urls = 0
for url in normalized_urls:
try:
added = bool(apobj.add(url))
except Exception:
added = False
if added:
valid_urls += 1
else:
invalid_urls += 1
if valid_urls == 0:
return {
"success": False,
"message": "No valid notification URLs configured",
}
try:
delivered = bool(apobj.notify(title=title, body=body, notify_type=notify_type))
except Exception as exc:
return {"success": False, "message": f"Notification send failed: {type(exc).__name__}: {exc}"}
if not delivered:
return {"success": False, "message": "Notification delivery failed"}
message = f"Notification sent to {valid_urls} URL(s)"
if invalid_urls:
message += f" ({invalid_urls} invalid URL(s) skipped)"
return {"success": True, "message": message}
def _create_apprise_client() -> Any:
if apprise is None:
return None
apprise_cls = getattr(apprise, "Apprise", None)
if apprise_cls is None:
return None
apprise_asset_cls = getattr(apprise, "AppriseAsset", None)
if apprise_asset_cls is None:
return apprise_cls()
try:
asset = apprise_asset_cls(
app_id=_APPRISE_APP_ID,
app_desc=_APPRISE_APP_DESC,
image_url_logo=_APPRISE_LOGO_URL,
)
except TypeError:
# Support older Apprise versions that do not expose image_url_logo.
asset = apprise_asset_cls(
app_id=_APPRISE_APP_ID,
app_desc=_APPRISE_APP_DESC,
)
except Exception:
return apprise_cls()
try:
return apprise_cls(asset=asset)
except Exception:
return apprise_cls()
def _send_admin_event(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> dict[str, Any]:
title, body = _render_message(context)
notify_type = _resolve_notify_type(event)
return _dispatch_to_apprise(urls, title=title, body=body, notify_type=notify_type)
def notify_admin(event: NotificationEvent, context: NotificationContext) -> None:
"""Send a global admin notification for an event if subscribed."""
routes = _resolve_admin_routes()
urls = _resolve_route_urls_for_event(routes, event)
if not urls:
return
try:
_executor.submit(_dispatch_admin_async, event, context, urls)
except Exception as exc:
logger.warning("Failed to queue admin notification '%s': %s", event.value, exc)
def notify_user(user_id: int | None, event: NotificationEvent, context: NotificationContext) -> None:
"""Send a per-user notification for an event if subscribed."""
normalized_user_id = _normalize_user_id(user_id)
if normalized_user_id is None:
return
routes = _resolve_user_routes(normalized_user_id)
urls = _resolve_route_urls_for_event(routes, event)
if not urls:
return
try:
_executor.submit(_dispatch_user_async, normalized_user_id, event, context, urls)
except Exception as exc:
logger.warning(
"Failed to queue user notification '%s' for user_id=%s: %s",
event.value,
normalized_user_id,
exc,
)
def _dispatch_admin_async(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> None:
result = _send_admin_event(event, context, urls)
if not result.get("success", False):
logger.warning("Admin notification failed for event '%s': %s", event.value, result.get("message"))
def _dispatch_user_async(
user_id: int,
event: NotificationEvent,
context: NotificationContext,
urls: list[str],
) -> None:
result = _send_admin_event(event, context, urls)
if not result.get("success", False):
logger.warning(
"User notification failed for event '%s' (user_id=%s): %s",
event.value,
user_id,
result.get("message"),
)
def send_test_notification(urls: list[str]) -> dict[str, Any]:
"""Send a synchronous test notification to the provided URLs."""
normalized_urls = _normalize_urls(urls)
if not normalized_urls:
return {"success": False, "message": "No notification URLs configured"}
test_context = NotificationContext(
event=NotificationEvent.REQUEST_CREATED,
title="Shelfmark Test Notification",
author="Shelfmark",
username="Shelfmark",
)
return _send_admin_event(NotificationEvent.REQUEST_CREATED, test_context, normalized_urls)
+35 -1
View File
@@ -6,6 +6,7 @@ Business logic remains in oidc_auth.py.
from typing import Any
from authlib.jose.errors import InvalidClaimError
from authlib.integrations.flask_client import OAuth
from flask import Flask, jsonify, redirect, request, session
@@ -119,7 +120,40 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
return jsonify({"error": "Authentication failed"}), 400
client, config = _get_oidc_client()
token = client.authorize_access_token()
try:
token = client.authorize_access_token()
except InvalidClaimError as e:
claim_name = getattr(e, "claim_name", "unknown")
discovery_url = str(config.get("OIDC_DISCOVERY_URL", ""))
provider_issuer = ""
try:
metadata = client.load_server_metadata()
if isinstance(metadata, dict):
provider_issuer = str(metadata.get("issuer", ""))
except Exception as metadata_error:
logger.debug(f"OIDC metadata lookup failed during claim diagnostics: {metadata_error}")
logger.error(
"OIDC callback claim validation failed: claim=%s error=%s discovery_url=%s provider_issuer=%s",
claim_name,
e,
discovery_url or "<unset>",
provider_issuer or "<unknown>",
)
if claim_name == "iss":
return (
jsonify(
{
"error": (
"OIDC issuer validation failed. Verify your discovery URL and IdP issuer/"
"external URL configuration."
)
}
),
400,
)
return jsonify({"error": f"OIDC token claim validation failed: {claim_name}"}), 400
claims = _normalize_claims(token.get("userinfo"))
# If userinfo isn't present in token payload, request it explicitly.
+205 -24
View File
@@ -25,6 +25,12 @@ from shelfmark.core.requests_service import (
reject_request,
)
from shelfmark.core.activity_service import ActivityService, build_request_item_key
from shelfmark.core.notifications import (
NotificationContext,
NotificationEvent,
notify_admin,
notify_user,
)
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
@@ -188,6 +194,127 @@ def _record_terminal_request_snapshot(
logger.warning("Failed to record terminal request snapshot for request %s: %s", request_id, exc)
def _normalize_optional_text(value: Any) -> str | None:
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized or None
def _resolve_title_from_book_data(book_data: Any) -> str:
if isinstance(book_data, dict):
title = _normalize_optional_text(book_data.get("title"))
if title is not None:
return title
return "Unknown title"
def _resolve_request_title(request_row: dict[str, Any]) -> str:
return _resolve_title_from_book_data(request_row.get("book_data"))
def _format_user_label(username: str | None, user_id: int | None = None) -> str:
normalized_username = _normalize_optional_text(username)
if normalized_username is not None:
return normalized_username
if user_id is not None and user_id > 0:
return f"user#{user_id}"
return "unknown user"
def _resolve_request_username(
user_db: UserDB,
*,
request_row: dict[str, Any],
fallback_username: str | None = None,
) -> str | None:
normalized_fallback = _normalize_optional_text(fallback_username)
raw_user_id = request_row.get("user_id")
try:
request_user_id = int(raw_user_id)
except (TypeError, ValueError):
return normalized_fallback
requester = user_db.get_user(user_id=request_user_id)
if not isinstance(requester, dict):
return normalized_fallback
return _normalize_optional_text(requester.get("username")) or normalized_fallback
def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str, str | None]:
release_data = request_row.get("release_data")
if isinstance(release_data, dict):
source = normalize_source(release_data.get("source") or request_row.get("source_hint"))
release_format = _normalize_optional_text(
release_data.get("format")
or release_data.get("filetype")
or release_data.get("extension")
)
return source, release_format
return normalize_source(request_row.get("source_hint")), None
def _resolve_request_user_id(request_row: dict[str, Any]) -> int | None:
raw_user_id = request_row.get("user_id")
try:
user_id = int(raw_user_id)
except (TypeError, ValueError):
return None
return user_id if user_id > 0 else None
def _notify_admin_for_request_event(
user_db: UserDB,
*,
event: NotificationEvent,
request_row: dict[str, Any],
fallback_username: str | None = None,
) -> None:
book_data = request_row.get("book_data")
if not isinstance(book_data, dict):
book_data = {}
source, release_format = _resolve_request_source_and_format(request_row)
context = NotificationContext(
event=event,
title=str(book_data.get("title") or "Unknown title"),
author=str(book_data.get("author") or "Unknown author"),
username=_resolve_request_username(
user_db,
request_row=request_row,
fallback_username=fallback_username,
),
content_type=normalize_content_type(
request_row.get("content_type") or book_data.get("content_type")
),
format=release_format,
source=source,
admin_note=_normalize_optional_text(request_row.get("admin_note")),
error_message=None,
)
owner_user_id = _resolve_request_user_id(request_row)
try:
notify_admin(event, context)
except Exception as exc:
logger.warning(
"Failed to trigger admin notification for request event '%s': %s",
event.value,
exc,
)
if owner_user_id is None:
return
try:
notify_user(owner_user_id, event, context)
except Exception as exc:
logger.warning(
"Failed to trigger user notification for request event '%s' (user_id=%s): %s",
event.value,
owner_user_id,
exc,
)
def register_request_routes(
app: Flask,
user_db: UserDB,
@@ -251,26 +378,6 @@ def register_request_routes(
}
)
logger.debug(
"request-policy snapshot user=%s db_user_id=%s is_admin=%s requests_enabled=%s defaults=%s",
session.get("user_id"),
db_user_id,
is_admin,
requests_enabled,
{
"ebook": (
default_ebook_mode.value
if default_ebook_mode is not None
else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value
),
"audiobook": (
default_audio_mode.value
if default_audio_mode is not None
else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value
),
},
)
return jsonify(
{
"requests_enabled": requests_enabled,
@@ -302,6 +409,8 @@ def register_request_routes(
db_user_id, db_gate = _require_db_user_id()
if db_gate is not None or db_user_id is None:
return db_gate
actor_username = _normalize_optional_text(session.get("user_id"))
actor_label = _format_user_label(actor_username, db_user_id)
data = request.get_json(silent=True)
if not isinstance(data, dict):
@@ -320,6 +429,7 @@ def register_request_routes(
book_data = data.get("book_data")
if not isinstance(book_data, dict):
return jsonify({"error": "book_data must be an object"}), 400
request_title = _resolve_title_from_book_data(book_data)
content_type = normalize_content_type(
context.get("content_type")
@@ -332,6 +442,11 @@ def register_request_routes(
db_user_id=db_user_id,
)
if not requests_enabled:
logger.debug(
"Request not created for '%s' by %s: requests are disabled",
request_title,
actor_label,
)
return _error_response(
"Request workflow is disabled by policy",
403,
@@ -366,6 +481,11 @@ def register_request_routes(
)
if resolved_mode == PolicyMode.BLOCKED:
logger.debug(
"Request blocked by policy for '%s' by %s",
request_title,
actor_label,
)
return _error_response(
"Requesting is blocked by policy",
403,
@@ -376,6 +496,11 @@ def register_request_routes(
if resolved_mode == PolicyMode.REQUEST_BOOK:
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
if requested_level != "book":
logger.debug(
"Request not created for '%s' by %s: policy requires book-level requests",
request_title,
actor_label,
)
return _error_response(
"Policy requires book-level requests",
403,
@@ -402,8 +527,14 @@ def register_request_routes(
event_payload = {
"request_id": created["id"],
"status": created["status"],
"title": (created.get("book_data") or {}).get("title") or "Unknown title",
"title": _resolve_request_title(created),
}
logger.info(
"Request created #%s for '%s' by %s",
created["id"],
event_payload["title"],
actor_label,
)
_emit_request_event(
ws_manager,
event_name="new_request",
@@ -417,6 +548,13 @@ def register_request_routes(
room=f"user_{db_user_id}",
)
_notify_admin_for_request_event(
user_db,
event=NotificationEvent.REQUEST_CREATED,
request_row=created,
fallback_username=actor_username,
)
return jsonify(created), 201
@app.route("/api/requests", methods=["GET"])
@@ -468,8 +606,15 @@ def register_request_routes(
event_payload = {
"request_id": updated["id"],
"status": updated["status"],
"title": (updated.get("book_data") or {}).get("title") or "Unknown title",
"title": _resolve_request_title(updated),
}
actor_label = _format_user_label(_normalize_optional_text(session.get("user_id")), db_user_id)
logger.info(
"Request cancelled #%s for '%s' by %s",
updated["id"],
event_payload["title"],
actor_label,
)
_emit_request_event(
ws_manager,
event_name="request_update",
@@ -567,8 +712,20 @@ def register_request_routes(
event_payload = {
"request_id": updated["id"],
"status": updated["status"],
"title": (updated.get("book_data") or {}).get("title") or "Unknown title",
"title": _resolve_request_title(updated),
}
admin_label = _format_user_label(_normalize_optional_text(session.get("user_id")), admin_user_id)
requester_label = _format_user_label(
_resolve_request_username(user_db, request_row=updated),
_resolve_request_user_id(updated),
)
logger.info(
"Request fulfilled #%s for '%s' by %s (requested by %s)",
updated["id"],
event_payload["title"],
admin_label,
requester_label,
)
_emit_request_event(
ws_manager,
event_name="request_update",
@@ -582,6 +739,12 @@ def register_request_routes(
room="admins",
)
_notify_admin_for_request_event(
user_db,
event=NotificationEvent.REQUEST_FULFILLED,
request_row=updated,
)
return jsonify(updated)
@app.route("/api/admin/requests/<int:request_id>/reject", methods=["POST"])
@@ -619,8 +782,20 @@ def register_request_routes(
event_payload = {
"request_id": updated["id"],
"status": updated["status"],
"title": (updated.get("book_data") or {}).get("title") or "Unknown title",
"title": _resolve_request_title(updated),
}
admin_label = _format_user_label(_normalize_optional_text(session.get("user_id")), admin_user_id)
requester_label = _format_user_label(
_resolve_request_username(user_db, request_row=updated),
_resolve_request_user_id(updated),
)
logger.info(
"Request rejected #%s for '%s' by %s (requested by %s)",
updated["id"],
event_payload["title"],
admin_label,
requester_label,
)
_emit_request_event(
ws_manager,
event_name="request_update",
@@ -634,4 +809,10 @@ def register_request_routes(
room="admins",
)
_notify_admin_for_request_event(
user_db,
event=NotificationEvent.REQUEST_REJECTED,
request_row=updated,
)
return jsonify(updated)
+7 -1
View File
@@ -356,7 +356,11 @@ def cancel_request(
)
try:
return user_db.update_request(request_id, status="cancelled")
return user_db.update_request(
request_id,
expected_current_status="pending",
status="cancelled",
)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
@@ -391,6 +395,7 @@ def reject_request(
try:
return user_db.update_request(
request_id,
expected_current_status="pending",
status="rejected",
admin_note=normalized_admin_note,
reviewed_by=admin_user_id,
@@ -466,6 +471,7 @@ def fulfil_request(
try:
return user_db.update_request(
request_id,
expected_current_status="pending",
status="fulfilled",
release_data=selected_release_data,
delivery_state="queued",
+37 -74
View File
@@ -7,7 +7,10 @@ from flask import Flask, jsonify, request, session
from werkzeug.security import generate_password_hash
from shelfmark.config.env import CWA_DB_PATH
from shelfmark.core.admin_settings_routes import validate_user_settings
from shelfmark.core.admin_settings_routes import (
build_user_notification_test_response,
validate_user_settings,
)
from shelfmark.core.auth_modes import (
AUTH_SOURCE_BUILTIN,
AUTH_SOURCE_CWA,
@@ -19,6 +22,10 @@ from shelfmark.core.auth_modes import (
)
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_settings_overrides import (
build_user_preferences_payload as _build_user_preferences_payload,
get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields,
)
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
@@ -101,78 +108,6 @@ def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, A
return payload
def _get_settings_registry():
# Ensure settings modules are loaded before reading registry metadata.
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
import shelfmark.config.users_settings # noqa: F401
from shelfmark.core import settings_registry
return settings_registry
def _get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]:
settings_registry = _get_settings_registry()
tab = settings_registry.get_settings_tab(tab_name)
if not tab:
return []
overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name)
return [(field.key, field) for field in tab.fields if field.key in overridable_map]
def _build_delivery_preferences_payload(user_db: UserDB, user_id: int) -> dict[str, Any]:
from shelfmark.core.config import config as app_config
settings_registry = _get_settings_registry()
ordered_fields = _get_ordered_user_overridable_fields("downloads")
if not ordered_fields:
raise ValueError("Downloads settings tab not found")
download_config = load_config_file("downloads")
user_settings = user_db.get_user_settings(user_id)
ordered_keys = [key for key, _ in ordered_fields]
fields_payload: list[dict[str, Any]] = []
global_values: dict[str, Any] = {}
effective: dict[str, dict[str, Any]] = {}
for key, field in ordered_fields:
serialized = settings_registry.serialize_field(field, "downloads", include_value=False)
serialized["fromEnv"] = bool(
field.env_supported and settings_registry.is_value_from_env(field)
)
fields_payload.append(serialized)
global_values[key] = app_config.get(key, field.default)
source = "default"
value = app_config.get(key, field.default, user_id=user_id)
if field.env_supported and settings_registry.is_value_from_env(field):
source = "env_var"
elif key in user_settings and user_settings[key] is not None:
source = "user_override"
value = user_settings[key]
elif key in download_config:
source = "global_config"
effective[key] = {"value": value, "source": source}
user_overrides = {
key: user_settings[key]
for key in ordered_keys
if key in user_settings and user_settings[key] is not None
}
return {
"tab": "downloads",
"keys": ordered_keys,
"fields": fields_payload,
"globalValues": global_values,
"userOverrides": user_overrides,
"effective": effective,
}
def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
"""Register self-service user endpoints."""
@@ -188,25 +123,51 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
serialized_user["settings"] = user_db.get_user_settings(user_id)
try:
delivery_preferences = _build_delivery_preferences_payload(user_db, user_id)
delivery_preferences = _build_user_preferences_payload(user_db, user_id, "downloads")
except ValueError:
return jsonify({"error": "Downloads settings tab not found"}), 500
except Exception as exc:
logger.warning(f"Failed to build user delivery preferences for user_id={user_id}: {exc}")
delivery_preferences = None
try:
notification_preferences = _build_user_preferences_payload(user_db, user_id, "notifications")
except ValueError:
return jsonify({"error": "Notifications settings tab not found"}), 500
except Exception as exc:
logger.warning(f"Failed to build user notification preferences for user_id={user_id}: {exc}")
notification_preferences = None
user_overridable_keys = sorted(
set(delivery_preferences.get("keys", []) if delivery_preferences else [])
| set(notification_preferences.get("keys", []) if notification_preferences else [])
)
return jsonify(
{
"user": serialized_user,
"deliveryPreferences": delivery_preferences,
"notificationPreferences": notification_preferences,
"userOverridableKeys": user_overridable_keys,
}
)
@app.route("/api/users/me/notification-preferences/test", methods=["POST"])
@_require_authenticated_user
def users_me_test_notification_preferences():
user_id, _user, user_error = _get_current_user(user_db)
if user_error:
return user_error
if user_id is None:
return jsonify({"error": "User not found"}), 404
payload = request.get_json(silent=True)
result, status_code = build_user_notification_test_response(
user_id=user_id,
payload=payload,
)
return jsonify(result), status_code
@app.route("/api/users/me", methods=["PUT"])
@_require_authenticated_user
def users_me_update():
@@ -292,6 +253,8 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
allowed_user_settings_keys = {
key for key, _field in _get_ordered_user_overridable_fields("downloads")
} | {
key for key, _field in _get_ordered_user_overridable_fields("notifications")
}
disallowed_keys = sorted(
key for key in settings_payload if key not in allowed_user_settings_keys
+15 -2
View File
@@ -169,7 +169,6 @@ class UserDB:
conn.execute("PRAGMA journal_mode=WAL")
finally:
conn.close()
logger.info(f"User database initialized at {self._db_path}")
def _migrate_auth_source_column(self, conn: sqlite3.Connection) -> None:
"""Ensure users.auth_source exists and backfill historical rows."""
@@ -617,12 +616,21 @@ class UserDB:
"delivery_updated_at",
}
def update_request(self, request_id: int, **kwargs) -> Dict[str, Any]:
def update_request(
self,
request_id: int,
expected_current_status: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""Update request fields and return the updated record."""
if not kwargs:
request = self.get_request(request_id)
if request is None:
raise ValueError(f"Request {request_id} not found")
if expected_current_status is not None:
normalized_expected_status = normalize_request_status(expected_current_status)
if request["status"] != normalized_expected_status:
raise ValueError("Request state changed before update")
return request
for key in kwargs:
@@ -640,6 +648,11 @@ class UserDB:
if current is None:
raise ValueError(f"Request {request_id} not found")
if expected_current_status is not None:
normalized_expected_status = normalize_request_status(expected_current_status)
if current["status"] != normalized_expected_status:
raise ValueError("Request state changed before update")
updates = dict(kwargs)
if "status" in updates:
+78
View File
@@ -0,0 +1,78 @@
"""Shared helpers for user-overridable settings metadata and payloads."""
from typing import Any
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
def get_settings_registry():
# Ensure settings modules are loaded before reading registry metadata.
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
import shelfmark.config.notifications_settings # noqa: F401
import shelfmark.config.users_settings # noqa: F401
from shelfmark.core import settings_registry
return settings_registry
def get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]:
settings_registry = get_settings_registry()
tab = settings_registry.get_settings_tab(tab_name)
if not tab:
return []
overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name)
return [(field.key, field) for field in tab.fields if field.key in overridable_map]
def build_user_preferences_payload(user_db: UserDB, user_id: int, tab_name: str) -> dict[str, Any]:
from shelfmark.core.config import config as app_config
settings_registry = get_settings_registry()
ordered_fields = get_ordered_user_overridable_fields(tab_name)
if not ordered_fields:
tab_label = tab_name.capitalize()
raise ValueError(f"{tab_label} settings tab not found")
tab_config = load_config_file(tab_name)
user_settings = user_db.get_user_settings(user_id)
ordered_keys = [key for key, _ in ordered_fields]
fields_payload: list[dict[str, Any]] = []
global_values: dict[str, Any] = {}
effective: dict[str, dict[str, Any]] = {}
for key, field in ordered_fields:
serialized = settings_registry.serialize_field(field, tab_name, include_value=False)
serialized["fromEnv"] = bool(field.env_supported and settings_registry.is_value_from_env(field))
fields_payload.append(serialized)
global_values[key] = app_config.get(key, field.default)
source = "default"
value = app_config.get(key, field.default, user_id=user_id)
if field.env_supported and settings_registry.is_value_from_env(field):
source = "env_var"
elif key in user_settings and user_settings[key] is not None:
source = "user_override"
value = user_settings[key]
elif key in tab_config:
source = "global_config"
effective[key] = {"value": value, "source": source}
user_overrides = {
key: user_settings[key]
for key in ordered_keys
if key in user_settings and user_settings[key] is not None
}
return {
"tab": tab_name,
"keys": ordered_keys,
"fields": fields_payload,
"globalValues": global_values,
"userOverrides": user_overrides,
"effective": effective,
}
+72 -2
View File
@@ -49,6 +49,7 @@ from shelfmark.core.requests_service import (
sync_delivery_states_from_queue_status,
)
from shelfmark.core.activity_service import ActivityService, build_download_item_key
from shelfmark.core.notifications import NotificationContext, NotificationEvent, notify_admin, notify_user
from shelfmark.core.utils import normalize_base_path
from shelfmark.api.websocket import ws_manager
@@ -1012,7 +1013,73 @@ def _queue_status_to_final_activity_status(status: QueueStatus) -> str | None:
return None
def _normalize_optional_text(value: Any) -> str | None:
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized or None
def _queue_status_to_notification_event(status: QueueStatus) -> NotificationEvent | None:
if status in {QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.DONE}:
return NotificationEvent.DOWNLOAD_COMPLETE
if status == QueueStatus.ERROR:
return NotificationEvent.DOWNLOAD_FAILED
return None
def _notify_admin_for_terminal_download_status(*, task_id: str, status: QueueStatus, task: Any) -> None:
event = _queue_status_to_notification_event(status)
if event is None:
return
raw_owner_user_id = getattr(task, "user_id", None)
try:
owner_user_id = int(raw_owner_user_id) if raw_owner_user_id is not None else None
except (TypeError, ValueError):
owner_user_id = None
content_type = _normalize_optional_text(getattr(task, "content_type", None))
context = NotificationContext(
event=event,
title=str(getattr(task, "title", "Unknown title") or "Unknown title"),
author=str(getattr(task, "author", "Unknown author") or "Unknown author"),
username=_normalize_optional_text(getattr(task, "username", None)),
content_type=normalize_content_type(content_type) if content_type is not None else None,
format=_normalize_optional_text(getattr(task, "format", None)),
source=normalize_source(getattr(task, "source", None)),
error_message=(
_normalize_optional_text(getattr(task, "status_message", None))
if event == NotificationEvent.DOWNLOAD_FAILED
else None
),
)
try:
notify_admin(event, context)
except Exception as exc:
logger.warning(
"Failed to trigger admin notification for download %s (%s): %s",
task_id,
status.value,
exc,
)
if owner_user_id is None:
return
try:
notify_user(owner_user_id, event, context)
except Exception as exc:
logger.warning(
"Failed to trigger user notification for download %s (%s, user_id=%s): %s",
task_id,
status.value,
owner_user_id,
exc,
)
def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task: Any) -> None:
_notify_admin_for_terminal_download_status(task_id=task_id, status=status, task=task)
if activity_service is None:
return
@@ -1106,8 +1173,7 @@ def _is_graduated_request_download(task_id: str, *, user_id: int) -> bool:
return False
if activity_service is not None:
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
def _emit_request_update_events(updated_requests: list[dict[str, Any]]) -> None:
@@ -2124,6 +2190,7 @@ def api_settings_get_all() -> Union[Response, Tuple[Response, int]]:
# This triggers the @register_settings decorators
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
import shelfmark.config.notifications_settings # noqa: F401
data = serialize_all_settings(include_values=True)
return jsonify(data)
@@ -2153,6 +2220,7 @@ def api_settings_get_tab(tab_name: str) -> Union[Response, Tuple[Response, int]]
# Ensure settings are registered
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
import shelfmark.config.notifications_settings # noqa: F401
tab = get_settings_tab(tab_name)
if not tab:
@@ -2188,6 +2256,7 @@ def api_settings_update_tab(tab_name: str) -> Union[Response, Tuple[Response, in
# Ensure settings are registered
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
import shelfmark.config.notifications_settings # noqa: F401
tab = get_settings_tab(tab_name)
if not tab:
@@ -2234,6 +2303,7 @@ def api_settings_execute_action(tab_name: str, action_key: str) -> Union[Respons
# Ensure settings are registered
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
import shelfmark.config.notifications_settings # noqa: F401
# Get current form values if provided (for testing with unsaved values)
current_values = request.get_json(silent=True) or {}
+15 -3
View File
@@ -947,6 +947,10 @@ function App() {
[openRequestConfirmation, refreshRequestPolicy]
);
const handleReleaseModalPolicyRefresh = useCallback(() => {
return refreshRequestPolicy({ force: true });
}, [refreshRequestPolicy]);
const handleRequestCancel = useCallback(
async (requestId: number) => {
try {
@@ -978,12 +982,20 @@ function App() {
);
const handleRequestApprove = useCallback(
async (requestId: number, record: RequestRecord) => {
async (
requestId: number,
record: RequestRecord,
options?: {
browseOnly?: boolean;
}
) => {
if (!requestRoleIsAdmin) {
return;
}
if (record.request_level === 'release') {
const shouldBrowse = Boolean(options?.browseOnly) || record.request_level === 'book';
if (!shouldBrowse && record.request_level === 'release') {
try {
await fulfilSidebarRequest(requestId, record.release_data || undefined);
await refreshActivitySnapshot();
@@ -1252,7 +1264,7 @@ function App() {
onDownload={isBrowseFulfilMode ? handleBrowseFulfilDownload : handleReleaseDownload}
onRequestRelease={isBrowseFulfilMode ? undefined : handleReleaseRequest}
getPolicyModeForSource={isBrowseFulfilMode ? () => 'download' : (source, ct) => getSourceMode(source, ct)}
onPolicyRefresh={() => refreshRequestPolicy({ force: true })}
onPolicyRefresh={handleReleaseModalPolicyRefresh}
supportedFormats={supportedFormats}
supportedAudiobookFormats={config?.supported_audiobook_formats || []}
contentType={activeReleaseContentType}
+8 -1
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Book, ButtonStateInfo, isMetadataBook } from '../types';
import { isUserCancelledError } from '../utils/errors';
@@ -122,7 +123,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400';
const infoValueClass = 'text-gray-900 dark:text-gray-100';
return (
const modal = (
<div
className="modal-overlay active sm:px-6 sm:py-6"
onClick={e => {
@@ -355,4 +356,10 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
</div>
</div>
);
if (typeof document === 'undefined') {
return modal;
}
return createPortal(modal, document.body);
};
+9 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
Book,
Release,
@@ -687,7 +688,7 @@ export const ReleaseModal = ({
useEffect(() => {
if (!book || !onPolicyRefresh) return;
void onPolicyRefresh();
}, [book, onPolicyRefresh]);
}, [book?.id, onPolicyRefresh]);
// Close handler with animation
const handleClose = useCallback(() => {
@@ -1319,7 +1320,7 @@ export const ReleaseModal = ({
const currentTabError = errorBySource[activeTab] ?? null;
const isInitialLoading = currentTabLoading || (releasesBySource[activeTab] === undefined && !currentTabError);
return (
const modal = (
<div
className="modal-overlay active sm:px-6 sm:py-6"
onClick={(e) => {
@@ -1971,4 +1972,10 @@ export const ReleaseModal = ({
</div>
</div>
);
if (typeof document === 'undefined') {
return modal;
}
return createPortal(modal, document.body);
};
@@ -1,4 +1,4 @@
import { ReactNode, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { RequestRecord } from '../../types';
import { withBasePath } from '../../utils/basePath';
import { Tooltip } from '../shared/Tooltip';
@@ -10,15 +10,34 @@ import {
getProgressConfig,
} from './activityStyles';
interface RequestApproveOptions {
browseOnly?: boolean;
}
type RequestApproveHandler = (
requestId: number,
record: RequestRecord,
options?: RequestApproveOptions
) => Promise<void> | void;
interface ActivityCardProps {
item: ActivityItem;
isAdmin: boolean;
onDownloadCancel?: (bookId: string) => void;
onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void;
onRequestCancel?: (requestId: number) => void;
onRequestApprove?: (requestId: number, record: RequestRecord) => void;
onRequestReject?: (requestId: number) => void;
onRequestApprove?: RequestApproveHandler;
onRequestReviewApprove?: RequestApproveHandler;
onRequestReject?: (requestId: number, adminNote?: string) => Promise<void> | void;
onRequestRejectConfirm?: (requestId: number, adminNote?: string) => Promise<void> | void;
onRequestDismiss?: (requestId: number) => void;
showRequestDetailsToggle?: boolean;
isRequestDetailsOpen?: boolean;
onRequestDetailsToggle?: () => void;
onRequestDetailsOpen?: () => void;
isRequestRejectOpen?: boolean;
onRequestRejectClose?: () => void;
isSelected?: boolean;
}
const BookFallback = () => (
@@ -142,6 +161,68 @@ const ActionIcon = ({ icon }: { icon: 'cross' | 'check' | 'stop' }) => {
);
};
const asRecord = (value: unknown): Record<string, unknown> => {
if (value && typeof value === 'object') {
return value as Record<string, unknown>;
}
return {};
};
const toOptionalText = (value: unknown): string | undefined => {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
return undefined;
};
const toSourceLabel = (value: unknown): string => {
const text = toOptionalText(value);
if (!text) {
return 'Any Source';
}
const normalized = text.trim().toLowerCase();
if (normalized === '*' || normalized === 'any' || normalized === 'all') {
return 'Any Source';
}
return text
.split(/[_\s-]+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
};
const formatDateTime = (isoDate: string): string => {
const parsed = Date.parse(isoDate);
if (!Number.isFinite(parsed)) {
return isoDate;
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(parsed);
};
const hasAttachedReleaseData = (record: RequestRecord): boolean => {
if (record.request_level !== 'release') {
return false;
}
if (!record.release_data || typeof record.release_data !== 'object') {
return false;
}
return Object.keys(record.release_data).length > 0;
};
const DetailField = ({ label, value }: { label: string; value: string }) => (
<div className="py-1">
<p className="text-[10px] uppercase tracking-wide opacity-60">{label}</p>
<p className="text-xs font-medium break-words mt-0.5">{value}</p>
</div>
);
const MAX_ADMIN_NOTE_LENGTH = 1000;
export const ActivityCard = ({
item,
isAdmin,
@@ -149,13 +230,27 @@ export const ActivityCard = ({
onDownloadDismiss,
onRequestCancel,
onRequestApprove,
onRequestReviewApprove,
onRequestReject,
onRequestRejectConfirm,
onRequestDismiss,
showRequestDetailsToggle = false,
isRequestDetailsOpen = false,
onRequestDetailsToggle,
onRequestDetailsOpen,
isRequestRejectOpen = false,
onRequestRejectClose,
isSelected = false,
}: ActivityCardProps) => {
const model = useMemo(() => buildActivityCardModel(item, isAdmin), [item, isAdmin]);
const noteLine = model.noteLine;
const badgeRefs = useRef<Record<string, HTMLSpanElement | null>>({});
const titleLineRef = useRef<HTMLParagraphElement | null>(null);
const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({});
const [titleOverflow, setTitleOverflow] = useState(false);
const [isReviewSubmitting, setIsReviewSubmitting] = useState(false);
const [rejectNote, setRejectNote] = useState('');
const [isRejectSubmitting, setIsRejectSubmitting] = useState(false);
useLayoutEffect(() => {
const measureBadgeOverflow = () => {
@@ -200,6 +295,50 @@ export const ActivityCard = ({
return () => observer.disconnect();
}, [model.badges]);
useLayoutEffect(() => {
const measureTitleOverflow = () => {
const element = titleLineRef.current;
const nextOverflow = Boolean(
element && element.scrollWidth - element.clientWidth > 1
);
setTitleOverflow((current) => (current === nextOverflow ? current : nextOverflow));
};
measureTitleOverflow();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measureTitleOverflow);
return () => window.removeEventListener('resize', measureTitleOverflow);
}
const observer = new ResizeObserver(measureTitleOverflow);
if (titleLineRef.current) {
observer.observe(titleLineRef.current);
}
return () => observer.disconnect();
}, [item.title, item.author]);
const reviewRecord = item.requestRecord;
const reviewApproveHandler = onRequestReviewApprove || onRequestApprove;
const isDetailsExpanded = isRequestDetailsOpen || isRequestRejectOpen;
useEffect(() => {
if (!isRequestDetailsOpen) {
setIsReviewSubmitting(false);
return;
}
}, [isRequestDetailsOpen, reviewRecord?.id, reviewRecord?.updated_at]);
useEffect(() => {
if (!isRequestRejectOpen) {
setRejectNote('');
setIsRejectSubmitting(false);
return;
}
setRejectNote('');
}, [isRequestRejectOpen, reviewRecord?.id, reviewRecord?.updated_at]);
const runAction = (action: ActivityCardAction) => {
switch (action.kind) {
case 'download-remove':
@@ -210,6 +349,16 @@ export const ActivityCard = ({
onDownloadDismiss?.(action.bookId, action.linkedRequestId);
break;
case 'request-approve':
if (showRequestDetailsToggle && hasAttachedReleaseData(action.record)) {
if (!isRequestDetailsOpen) {
if (onRequestDetailsOpen) {
onRequestDetailsOpen();
} else if (onRequestDetailsToggle) {
onRequestDetailsToggle();
}
}
break;
}
onRequestApprove?.(action.requestId, action.record);
break;
case 'request-reject':
@@ -248,6 +397,80 @@ export const ActivityCard = ({
const actions = model.actions.filter(hasActionHandler);
const bookData = asRecord(reviewRecord?.book_data);
const releaseData = asRecord(reviewRecord?.release_data);
const bookTitle = toOptionalText(bookData.title) || 'Unknown title';
const fileTitle = toOptionalText(releaseData.title) || bookTitle;
const fileFormat =
toOptionalText(releaseData.format) ||
toOptionalText(releaseData.filetype) ||
toOptionalText(releaseData.extension) ||
'Unknown';
const fileSize = toOptionalText(releaseData.size) || 'Unknown';
const sourceLabel = toSourceLabel(
releaseData.source_display_name || releaseData.source || reviewRecord?.source_hint
);
const hasAttachedRelease =
reviewRecord?.request_level === 'release' && Object.keys(releaseData).length > 0;
const requiresBrowseBeforeApprove =
reviewRecord?.request_level === 'book' || !hasAttachedRelease;
const showSourceField = reviewRecord?.request_level === 'release';
const approveLabel =
requiresBrowseBeforeApprove
? 'Browse Releases To Approve'
: 'Approve Attached File';
const provider = toOptionalText(bookData.provider)?.toLowerCase();
const providerId = toOptionalText(bookData.provider_id);
const canBrowseAlternatives = Boolean(provider && providerId && provider !== 'direct_download');
const handleReviewApprove = async () => {
if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) {
return;
}
setIsReviewSubmitting(true);
try {
if (requiresBrowseBeforeApprove) {
await reviewApproveHandler(reviewRecord.id, reviewRecord, { browseOnly: true });
return;
}
await reviewApproveHandler(reviewRecord.id, reviewRecord);
} finally {
setIsReviewSubmitting(false);
}
};
const handleReviewBrowseAlternatives = async () => {
if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) {
return;
}
setIsReviewSubmitting(true);
try {
await reviewApproveHandler(reviewRecord.id, reviewRecord, { browseOnly: true });
} finally {
setIsReviewSubmitting(false);
}
};
const canShowInlineReview = Boolean(isRequestDetailsOpen && reviewRecord && reviewApproveHandler);
const rejectConfirmHandler = onRequestRejectConfirm || onRequestReject;
const canShowInlineReject = Boolean(
isRequestRejectOpen &&
item.requestId &&
rejectConfirmHandler
);
const requestedAt = reviewRecord ? formatDateTime(reviewRecord.created_at) : '';
const requestType = reviewRecord?.content_type === 'audiobook' ? 'Audiobook' : 'Book';
const titleAuthorLine = item.author ? `${item.title}${item.author}` : item.title;
const titleLineClassName = isDetailsExpanded
? 'text-sm leading-tight min-w-0 whitespace-normal break-words'
: 'text-sm truncate leading-tight min-w-0';
const titleNode =
item.kind === 'download' &&
item.visualStatus === 'complete' &&
@@ -263,8 +486,32 @@ export const ActivityCard = ({
item.title
);
const handleInlineRejectConfirm = async () => {
if (!item.requestId || !rejectConfirmHandler || isRejectSubmitting) {
return;
}
setIsRejectSubmitting(true);
try {
const trimmed = rejectNote.trim();
await rejectConfirmHandler(item.requestId, trimmed || undefined);
} finally {
setIsRejectSubmitting(false);
}
};
return (
<div className="px-4 py-2 -mx-4 hover-row cursor-default">
<div
className={`px-4 py-2 -mx-4 cursor-default ${
isSelected ? 'relative' : 'hover-row'
}`}
>
{isSelected && (
<span
aria-hidden="true"
className="absolute left-0 top-2 bottom-2 w-1 bg-sky-500/80"
/>
)}
<div className="flex gap-3 items-start">
{/* Artwork */}
<div className="w-12 h-[4.5rem] rounded flex-shrink-0 overflow-hidden bg-gray-200 dark:bg-gray-700">
@@ -282,10 +529,18 @@ export const ActivityCard = ({
{/* Content */}
<div className="flex-1 min-w-0 py-0.5">
<div className="flex items-start justify-between gap-2">
<p className="text-sm truncate leading-tight min-w-0" title={`${item.title}${item.author}`}>
<span className="font-semibold">{titleNode}</span>
{item.author && <span className="opacity-60 text-xs"> {item.author}</span>}
</p>
<div className="flex-1 min-w-0">
<Tooltip
content={!isDetailsExpanded && titleOverflow ? titleAuthorLine : undefined}
delay={0}
position="bottom"
>
<p ref={titleLineRef} className={titleLineClassName}>
<span className="font-semibold">{titleNode}</span>
{item.author && <span className="opacity-60 text-xs"> {item.author}</span>}
</p>
</Tooltip>
</div>
<div className="flex-shrink-0 inline-flex items-center gap-1 -my-1">
{actions.map((action) => {
const config = actionUiConfig(action);
@@ -306,6 +561,24 @@ export const ActivityCard = ({
</Tooltip>
);
})}
{showRequestDetailsToggle && onRequestDetailsToggle && (
<IconButton
title={isDetailsExpanded ? 'Hide details' : 'Show details'}
className="text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={onRequestDetailsToggle}
>
<svg
className={`w-4 h-4 transition-transform ${isDetailsExpanded ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m6 9 6 6 6-6" />
</svg>
</IconButton>
)}
</div>
</div>
@@ -370,6 +643,94 @@ export const ActivityCard = ({
);
})}
</div>
{canShowInlineReview && (
<div className="-mx-4 mt-2 px-4 pb-2 space-y-3 animate-fade-in">
<div className={`grid grid-cols-1 ${showSourceField ? 'sm:grid-cols-3' : 'sm:grid-cols-2'} gap-x-3 gap-y-1`}>
<DetailField label="Requested" value={requestedAt} />
<DetailField label="Type" value={requestType} />
{showSourceField && <DetailField label="Source" value={sourceLabel} />}
</div>
{hasAttachedRelease ? (
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-wide opacity-70">Attached File</p>
<div className="grid grid-cols-1 gap-x-3 gap-y-1">
<DetailField label="Title" value={fileTitle} />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
<DetailField label="Size" value={fileSize} />
<DetailField label="Format" value={String(fileFormat).toUpperCase()} />
</div>
</div>
) : (
<p className="text-xs opacity-70">
{reviewRecord?.request_level === 'book'
? 'This is a book-level request without an attached file. Choose a release before approval.'
: 'No attached release data is available. Choose a release before approval.'}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleReviewApprove}
disabled={isReviewSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs font-medium text-white bg-green-600 hover:bg-green-700 transition-colors disabled:opacity-60"
>
{isReviewSubmitting ? 'Working...' : approveLabel}
</button>
{canBrowseAlternatives && hasAttachedRelease && (
<button
type="button"
onClick={handleReviewBrowseAlternatives}
disabled={isReviewSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
>
Browse Alternatives
</button>
)}
</div>
</div>
)}
{canShowInlineReject && (
<div className="-mx-4 mt-2 px-4 pb-2 space-y-3 animate-fade-in">
<p className="text-xs font-medium">
Reject request for <span className="opacity-80">{item.title || 'Untitled request'}</span>
</p>
<textarea
value={rejectNote}
onChange={(event) => setRejectNote(event.target.value.slice(0, MAX_ADMIN_NOTE_LENGTH))}
rows={3}
maxLength={MAX_ADMIN_NOTE_LENGTH}
placeholder="Optional note shown to the user"
className="w-full px-2.5 py-2 rounded-md border border-[var(--border-muted)] bg-[var(--bg)] text-xs resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-red-500/30 focus:border-red-500"
disabled={isRejectSubmitting}
/>
<div className="flex items-center justify-between">
<span className="text-[11px] opacity-60">{rejectNote.length}/{MAX_ADMIN_NOTE_LENGTH}</span>
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={onRequestRejectClose}
disabled={isRejectSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleInlineRejectConfirm}
disabled={isRejectSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs font-medium text-white bg-red-600 hover:bg-red-700 transition-colors disabled:opacity-60"
>
{isRejectSubmitting ? 'Rejecting...' : 'Reject'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
@@ -3,7 +3,6 @@ import { RequestRecord, StatusData } from '../../types';
import { downloadToActivityItem, DownloadStatusKey } from './activityMappers';
import { ActivityItem } from './activityTypes';
import { ActivityCard } from './ActivityCard';
import { RejectDialog } from './RejectDialog';
import { Dropdown } from '../Dropdown';
interface ActivitySidebarProps {
@@ -26,7 +25,13 @@ interface ActivitySidebarProps {
showRequestsTab: boolean;
isRequestsLoading?: boolean;
onRequestCancel?: (requestId: number) => Promise<void> | void;
onRequestApprove?: (requestId: number, record: RequestRecord) => Promise<void> | void;
onRequestApprove?: (
requestId: number,
record: RequestRecord,
options?: {
browseOnly?: boolean;
}
) => Promise<void> | void;
onRequestReject?: (requestId: number, adminNote?: string) => Promise<void> | void;
onRequestDismiss?: (requestId: number) => void;
onPinnedOpenChange?: (pinnedOpen: boolean) => void;
@@ -258,7 +263,8 @@ export const ActivitySidebar = ({
const [isDesktop, setIsDesktop] = useState<boolean>(() => getInitialDesktopState());
const [activeTab, setActiveTab] = useState<ActivityTabKey>('all');
const [selectedUser, setSelectedUser] = useState<string>(ALL_USERS_FILTER);
const [rejectingRequest, setRejectingRequest] = useState<{ requestId: number; bookTitle: string } | null>(null);
const [rejectingRequest, setRejectingRequest] = useState<{ requestId: number } | null>(null);
const [reviewingRequestId, setReviewingRequestId] = useState<number | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const dismissedKeySet = useMemo(
@@ -294,6 +300,7 @@ export const ActivitySidebar = ({
useEffect(() => {
if (activeTab === 'downloads') {
setRejectingRequest(null);
setReviewingRequestId(null);
}
}, [activeTab]);
@@ -452,6 +459,42 @@ export const ActivitySidebar = ({
return baseVisibleItems.filter((item) => getItemUsername(item) === selectedUser);
}, [baseVisibleItems, selectedUser]);
useEffect(() => {
if (reviewingRequestId === null) {
return;
}
const hasMatchingPendingRequest = visibleItems.some((item) => {
return (
item.kind === 'request' &&
item.requestId === reviewingRequestId &&
item.requestRecord?.status === 'pending'
);
});
if (!hasMatchingPendingRequest) {
setReviewingRequestId(null);
}
}, [reviewingRequestId, visibleItems]);
useEffect(() => {
if (rejectingRequest === null) {
return;
}
const hasMatchingPendingRequest = visibleItems.some((item) => {
return (
item.kind === 'request' &&
item.requestId === rejectingRequest.requestId &&
item.requestRecord?.status === 'pending'
);
});
if (!hasMatchingPendingRequest) {
setRejectingRequest(null);
}
}, [rejectingRequest, visibleItems]);
const hasUserFilter = isAdmin && availableUsers.length > 1;
const clearCompletedTargets = useMemo(() => {
@@ -792,10 +835,23 @@ export const ActivitySidebar = ({
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
{group.items.map((item) => {
const showRequestActions = activeTab === 'requests' || activeTab === 'all';
const requestId = item.requestId;
const shouldShowRejectDialog =
showRequestActions &&
rejectingRequest !== null &&
item.requestId === rejectingRequest.requestId;
requestId === rejectingRequest.requestId;
const requestRecord = item.requestRecord;
const canShowRequestReview =
showRequestActions &&
isAdmin &&
item.kind === 'request' &&
typeof requestId === 'number' &&
requestRecord?.status === 'pending';
const shouldShowRequestReview =
canShowRequestReview &&
reviewingRequestId !== null &&
requestId === reviewingRequestId &&
requestRecord !== undefined;
return (
<div key={item.id}>
@@ -810,20 +866,55 @@ export const ActivitySidebar = ({
onRequestReject={
showRequestActions && onRequestReject
? (requestId) => {
const title = item.title || 'Untitled request';
setRejectingRequest({ requestId, bookTitle: title });
setReviewingRequestId(null);
setRejectingRequest({ requestId });
}
: undefined
}
showRequestDetailsToggle={canShowRequestReview}
isRequestDetailsOpen={shouldShowRequestReview}
isSelected={shouldShowRequestReview || shouldShowRejectDialog}
onRequestReviewApprove={
onRequestApprove
? async (requestId, record, options) => {
await onRequestApprove(requestId, record, options);
setReviewingRequestId(null);
}
: undefined
}
isRequestRejectOpen={shouldShowRejectDialog}
onRequestRejectClose={() => setRejectingRequest(null)}
onRequestRejectConfirm={
onRequestReject
? async (requestId, adminNote) => {
await onRequestReject(requestId, adminNote);
setRejectingRequest(null);
}
: undefined
}
onRequestDetailsToggle={
canShowRequestReview && typeof requestId === 'number'
? () => {
if (shouldShowRejectDialog) {
setRejectingRequest(null);
return;
}
setRejectingRequest(null);
setReviewingRequestId((current) => (
current === requestId ? null : requestId
));
}
: undefined
}
onRequestDetailsOpen={
canShowRequestReview && typeof requestId === 'number'
? () => {
setRejectingRequest(null);
setReviewingRequestId(requestId);
}
: undefined
}
/>
{shouldShowRejectDialog && onRequestReject && (
<RejectDialog
requestId={rejectingRequest.requestId}
bookTitle={rejectingRequest.bookTitle}
onConfirm={onRequestReject}
onCancel={() => setRejectingRequest(null)}
/>
)}
</div>
);
})}
@@ -889,8 +980,8 @@ export const ActivitySidebar = ({
<aside
className="hidden lg:flex fixed right-0 w-96 flex-col bg-[var(--bg-soft)] z-30 rounded-2xl shadow-lg overflow-hidden"
style={{
top: `calc(${pinnedTopOffset}px + 0.75rem)`,
height: `calc(100dvh - ${pinnedTopOffset}px - 1.5rem)`,
top: `${pinnedTopOffset}px`,
height: `calc(100dvh - ${pinnedTopOffset}px - 0.75rem)`,
right: '0.75rem',
}}
onWheel={handlePinnedWheel}
@@ -1,86 +0,0 @@
import { useEffect, useState } from 'react';
interface RejectDialogProps {
requestId: number;
bookTitle: string;
onConfirm: (requestId: number, adminNote?: string) => Promise<void> | void;
onCancel: () => void;
}
const MAX_ADMIN_NOTE_LENGTH = 1000;
export const RejectDialog = ({
requestId,
bookTitle,
onConfirm,
onCancel,
}: RejectDialogProps) => {
const [adminNote, setAdminNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const onEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isSubmitting) {
onCancel();
}
};
document.addEventListener('keydown', onEscape);
return () => document.removeEventListener('keydown', onEscape);
}, [isSubmitting, onCancel]);
const handleConfirm = async () => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
try {
const trimmed = adminNote.trim();
await onConfirm(requestId, trimmed || undefined);
onCancel();
} catch {
// Parent handler surfaces the error state/toast.
} finally {
setIsSubmitting(false);
}
};
return (
<div className="rounded-lg border border-[var(--border-muted)] bg-[var(--bg)] p-3 mt-2 space-y-2">
<p className="text-xs font-medium">
Reject request for <span className="opacity-80">{bookTitle}</span>
</p>
<textarea
value={adminNote}
onChange={(event) => setAdminNote(event.target.value.slice(0, MAX_ADMIN_NOTE_LENGTH))}
rows={3}
maxLength={MAX_ADMIN_NOTE_LENGTH}
placeholder="Optional note shown to the user"
className="w-full px-2.5 py-2 rounded-md border border-[var(--border-muted)] bg-[var(--bg-soft)] text-xs resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-red-500/30 focus:border-red-500"
disabled={isSubmitting}
/>
<div className="flex items-center justify-between">
<span className="text-[11px] opacity-60">{adminNote.length}/{MAX_ADMIN_NOTE_LENGTH}</span>
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={isSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs font-medium text-white bg-red-600 hover:bg-red-700 transition-colors disabled:opacity-60"
>
{isSubmitting ? 'Rejecting...' : 'Reject'}
</button>
</div>
</div>
</div>
);
};
@@ -111,12 +111,15 @@ const requestStatusToVisualStatus = (status: RequestRecord['status']): ActivityV
const buildRequestMetaLine = (
record: RequestRecord,
bookData: Record<string, unknown>,
releaseData: Record<string, unknown>,
viewerRole: 'user' | 'admin'
): string => {
if (record.request_level === 'book') {
const contentType = toOptionalText(record.content_type || bookData.content_type)?.toLowerCase();
const requestTypeLabel = contentType === 'audiobook' ? 'Audiobook request' : 'Book request';
const username = viewerRole === 'admin' ? toOptionalText(record.username) : undefined;
return joinMetaParts(['Book request', username]);
return joinMetaParts([requestTypeLabel, username]);
}
const format = toOptionalText(releaseData.format)?.toUpperCase();
@@ -147,7 +150,7 @@ export const requestToActivityItem = (
title: toText(bookData.title ?? releaseData.title, 'Unknown title'),
author: toText(bookData.author ?? releaseData.author, 'Unknown author'),
preview: toOptionalText(bookData.preview) || toOptionalText(releaseData.preview),
metaLine: buildRequestMetaLine(record, releaseData, viewerRole),
metaLine: buildRequestMetaLine(record, bookData, releaseData, viewerRole),
statusLabel: STATUS_LABELS[visualStatus],
adminNote: toOptionalText(record.admin_note),
timestamp,
@@ -3,14 +3,15 @@ import {
AdminUser,
DeliveryPreferencesResponse,
getSelfUserEditContext,
testSelfNotificationPreferences,
updateSelfUser,
} from '../../services/api';
import { SelectField } from './fields';
import { FieldWrapper } from './shared';
import { UserAccountCardContent, UserEditActions, UserIdentityHeader } from './users/UserCard';
import { UserOverridesSection } from './users/UserOverridesSection';
import { buildUserSettingsPayload } from './users/settingsPayload';
import { UserOverridesSections } from './users/UserOverridesSections';
import { PerUserSettings } from './users/types';
import { useUserOverridesState } from './users/useUserOverridesState';
import { getStoredThemePreference, setThemePreference, THEME_FIELD } from '../../utils/themePreference';
interface SelfSettingsModalProps {
@@ -21,18 +22,6 @@ interface SelfSettingsModalProps {
const MIN_PASSWORD_LENGTH = 4;
const normalizeUserSettings = (settings: PerUserSettings): PerUserSettings => {
const normalized: PerUserSettings = {};
Object.keys(settings).sort().forEach((key) => {
const typedKey = key as keyof PerUserSettings;
const value = settings[typedKey];
if (value !== null && value !== undefined) {
normalized[typedKey] = value;
}
});
return normalized;
};
const getPasswordError = (password: string, passwordConfirm: string): string | null => {
if (!password && !passwordConfirm) {
return null;
@@ -62,28 +51,40 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [originalUser, setOriginalUser] = useState<AdminUser | null>(null);
const [deliveryPreferences, setDeliveryPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [notificationPreferences, setNotificationPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [editPassword, setEditPassword] = useState('');
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [originalUserSettings, setOriginalUserSettings] = useState<PerUserSettings>({});
const [userOverridableSettings, setUserOverridableSettings] = useState<Set<string>>(new Set());
const [themeValue, setThemeValue] = useState<string>(getStoredThemePreference());
const preferenceGroups = useMemo(
() => [deliveryPreferences, notificationPreferences],
[deliveryPreferences, notificationPreferences]
);
const {
userSettings,
setUserSettings,
isUserOverridable,
currentSettingsPayload,
hasUserSettingsChanges: hasSettingsChanges,
applyUserOverridesContext,
} = useUserOverridesState({ preferenceGroups });
const loadEditContext = useCallback(async () => {
setIsLoading(true);
setLoadError(null);
try {
const context = await getSelfUserEditContext();
const normalizedSettings = normalizeUserSettings((context.user.settings || {}) as PerUserSettings);
setEditingUser(context.user);
setOriginalUser(context.user);
setDeliveryPreferences(context.deliveryPreferences || null);
setUserSettings(normalizedSettings);
setOriginalUserSettings(normalizedSettings);
setUserOverridableSettings(new Set(context.userOverridableKeys || []));
setNotificationPreferences(context.notificationPreferences || null);
applyUserOverridesContext({
settings: (context.user.settings || {}) as PerUserSettings,
userOverridableKeys: context.userOverridableKeys || [],
});
setEditPassword('');
setEditPasswordConfirm('');
} catch (error) {
@@ -91,7 +92,7 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings
} finally {
setIsLoading(false);
}
}, []);
}, [applyUserOverridesContext]);
useEffect(() => {
if (!isOpen) {
@@ -136,24 +137,6 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleClose]);
const isUserOverridable = useCallback(
(key: keyof PerUserSettings) => userOverridableSettings.has(String(key)),
[userOverridableSettings]
);
const currentSettingsPayload = useMemo(
() => buildUserSettingsPayload(userSettings, userOverridableSettings, deliveryPreferences),
[deliveryPreferences, userOverridableSettings, userSettings]
);
const originalSettingsPayload = useMemo(
() => buildUserSettingsPayload(originalUserSettings, userOverridableSettings, deliveryPreferences),
[deliveryPreferences, originalUserSettings, userOverridableSettings]
);
const hasSettingsChanges =
JSON.stringify(currentSettingsPayload) !== JSON.stringify(originalSettingsPayload);
const hasProfileChanges = Boolean(
editingUser
&& originalUser
@@ -167,6 +150,10 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings
const passwordError = getPasswordError(editPassword, editPasswordConfirm);
const hasChanges = hasSettingsChanges || hasProfileChanges || hasPasswordChanges;
const handleTestNotificationRoutes = useCallback((routes: Array<Record<string, unknown>>) => {
return testSelfNotificationPreferences(routes);
}, []);
const handleSave = useCallback(async () => {
if (!editingUser || !originalUser) {
return;
@@ -320,14 +307,15 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings
preferencesPanel={{
hideTitle: true,
children: (
<div className="space-y-5">
<UserOverridesSection
deliveryPreferences={deliveryPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={(updater) => setUserSettings(updater)}
/>
</div>
<UserOverridesSections
scope="self"
deliveryPreferences={deliveryPreferences}
notificationPreferences={notificationPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={setUserSettings}
onTestNotificationRoutes={handleTestNotificationRoutes}
/>
),
}}
/>
@@ -68,6 +68,12 @@ const getIcon = (iconName?: string) => {
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
);
case 'bell':
return (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0M3.124 7.5A8.969 8.969 0 0 1 5.292 3m13.416 0a8.969 8.969 0 0 1 2.168 4.5" />
</svg>
);
case 'beaker':
case 'wrench':
return (
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { AdminUser } from '../../../services/api';
import {
AdminUser,
testAdminUserNotificationPreferences,
} from '../../../services/api';
import { CustomSettingsFieldRendererProps } from './types';
import {
canCreateLocalUsersForAuthMode,
@@ -40,6 +43,7 @@ export const UsersManagementField = ({
setEditPasswordConfirm,
downloadDefaults,
deliveryPreferences,
notificationPreferences,
isUserOverridable,
userSettings,
setUserSettings,
@@ -72,6 +76,7 @@ export const UsersManagementField = ({
userSettings,
userOverridableSettings,
deliveryPreferences,
notificationPreferences,
onEditSaveSuccess: clearEditState,
});
@@ -170,6 +175,13 @@ export const UsersManagementField = ({
await handleSaveUserOverridesRef.current();
}, []);
const handleTestNotificationRoutes = useCallback(async (routes: Array<Record<string, unknown>>) => {
if (!editingUser) {
return { success: false, message: 'No user selected for notification test.' };
}
return testAdminUserNotificationPreferences(editingUser.id, routes);
}, [editingUser]);
useEffect(() => {
if (route.kind !== 'edit-overrides') {
onUiStateChange('hasChanges', false);
@@ -198,11 +210,13 @@ export const UsersManagementField = ({
hasChanges={hasUserSettingsChanges}
onBack={handleBackToEdit}
deliveryPreferences={deliveryPreferences}
notificationPreferences={notificationPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={(updater) => setUserSettings(updater)}
usersTab={usersTab}
globalUsersSettingsValues={values}
onTestNotificationRoutes={handleTestNotificationRoutes}
/>
);
}
@@ -17,7 +17,7 @@ export const HeadingField = ({ field }: HeadingFieldProps) => (
href={field.linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sky-500 hover:text-sky-400 underline"
className="underline text-sky-600 dark:text-sky-400"
>
{field.linkText || field.linkUrl}
</a>
@@ -13,6 +13,7 @@ interface MultiSelectFieldProps {
const COLLAPSE_THRESHOLD_OPTIONS = 12;
// Approximate height for ~4 rows of pills (pills are ~32px + 8px gap)
const COLLAPSED_HEIGHT = 156;
const ALL_OPTION_VALUE = 'all';
/**
* Sort options with selected items first, preserving relative order within each group
@@ -34,9 +35,42 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
// Dropdown variant - use DropdownList with checkboxes
if (field.variant === 'dropdown') {
const optionValues = field.options.map((opt) => opt.value);
const optionSet = new Set(optionValues);
const hasAllOption = optionSet.has(ALL_OPTION_VALUE);
const orderedOptions = hasAllOption
? [
...field.options.filter((opt) => opt.value === ALL_OPTION_VALUE),
...field.options.filter((opt) => opt.value !== ALL_OPTION_VALUE),
]
: field.options;
const nonAllValues = orderedOptions
.map((opt) => opt.value)
.filter((optValue) => optValue !== ALL_OPTION_VALUE);
const normalizeValues = (values: string[]): string[] => {
const deduped = new Set(
values
.map((entry) => String(entry ?? '').trim())
.filter((entry) => entry.length > 0 && optionSet.has(entry))
);
return orderedOptions
.map((opt) => opt.value)
.filter((optValue) => deduped.has(optValue));
};
const selectedExplicit = normalizeValues(selected);
const allSelected = hasAllOption && (
selectedExplicit.includes(ALL_OPTION_VALUE)
|| (
nonAllValues.length > 0
&& nonAllValues.every((optValue) => selectedExplicit.includes(optValue))
)
);
// Build parent -> children map for cascading selection
const parentChildMap = new Map<string, string[]>();
field.options.forEach((opt) => {
orderedOptions.forEach((opt) => {
if (opt.childOf) {
const children = parentChildMap.get(opt.childOf) || [];
children.push(opt.value);
@@ -45,8 +79,11 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
});
// Check which children are implicitly selected via parent
const selectedForCascade = allSelected
? selectedExplicit.filter((optValue) => optValue !== ALL_OPTION_VALUE)
: selectedExplicit;
const implicitlySelected = new Set<string>();
selected.forEach((val) => {
selectedForCascade.forEach((val) => {
const children = parentChildMap.get(val);
if (children) {
children.forEach((child) => implicitlySelected.add(child));
@@ -54,29 +91,67 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
});
// Build options with disabled state for implicitly selected children
const dropdownOptions = field.options.map((opt) => ({
const dropdownOptions = orderedOptions.map((opt) => ({
value: opt.value,
label: opt.label,
disabled: implicitlySelected.has(opt.value),
disabled: !allSelected && implicitlySelected.has(opt.value),
}));
// For display purposes, show both explicit and implicit selections
const displayValue = [...selected, ...Array.from(implicitlySelected)];
// For display purposes:
// - if "all" is active, check every option
// - otherwise show explicit + implicit parent/child selections
const displayValue = allSelected
? [ALL_OPTION_VALUE, ...nonAllValues]
: normalizeValues([...selectedExplicit, ...Array.from(implicitlySelected)]);
const handleDropdownChange = (newValue: string | string[]) => {
const arr = Array.isArray(newValue) ? newValue : [newValue];
// Filter out implicitly selected values - only store explicit selections
const explicitOnly = arr.filter((v) => !implicitlySelected.has(v));
const nextValues = normalizeValues(Array.isArray(newValue) ? newValue : [newValue]);
if (hasAllOption) {
const includesAll = nextValues.includes(ALL_OPTION_VALUE);
// When currently "all" is active:
// - unticking "all" clears everything
// - unticking a specific option converts to explicit subset
if (allSelected && !includesAll && nextValues.length === nonAllValues.length) {
onChange([]);
return;
}
if (allSelected && includesAll && nextValues.length < optionValues.length) {
onChange(nextValues.filter((value) => value !== ALL_OPTION_VALUE));
return;
}
if (includesAll) {
onChange([ALL_OPTION_VALUE]);
return;
}
// If user selects every specific option individually, collapse to "all".
if (
nonAllValues.length > 0
&& nonAllValues.every((optValue) => nextValues.includes(optValue))
) {
onChange([ALL_OPTION_VALUE]);
return;
}
}
// Filter out implicitly selected values - only store explicit selections.
const explicitOnly = nextValues.filter((entry) => !implicitlySelected.has(entry));
onChange(explicitOnly);
};
// Custom summary formatter - only count explicit selections
const summaryFormatter = () => {
if (selected.length === 0) {
return <span className="opacity-60">Select categories...</span>;
if (allSelected) {
return orderedOptions.find((opt) => opt.value === ALL_OPTION_VALUE)?.label || 'All';
}
const selectedLabels = selected
.map((v) => field.options.find((o) => o.value === v)?.label)
if (selectedExplicit.length === 0) {
return <span className="opacity-60">{field.placeholder || 'Select categories...'}</span>;
}
const selectedLabels = selectedExplicit
.map((v) => orderedOptions.find((o) => o.value === v)?.label)
.filter(Boolean);
if (selectedLabels.length === 1) {
return selectedLabels[0];
@@ -102,7 +177,7 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
multiple
showCheckboxes
keepOpenOnSelect
placeholder="Select categories..."
placeholder={field.placeholder || 'Select categories...'}
widthClassName="w-full"
summaryFormatter={summaryFormatter}
/>
@@ -1,6 +1,7 @@
import { useMemo, useEffect, CSSProperties } from 'react';
import { TableFieldConfig, TableFieldColumn } from '../../../types/settings';
import { MultiSelectFieldConfig, TableFieldConfig, TableFieldColumn } from '../../../types/settings';
import { DropdownList } from '../../DropdownList';
import { MultiSelectField } from './MultiSelectField';
interface TableFieldProps {
field: TableFieldConfig;
@@ -13,12 +14,28 @@ function defaultCellValue(column: TableFieldColumn): unknown {
if (column.defaultValue !== undefined) {
return column.defaultValue;
}
if (column.type === 'multiselect') {
return [];
}
if (column.type === 'checkbox') {
return false;
}
return '';
}
function normalizeMultiValue(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((entry) => String(entry ?? '').trim())
.filter((entry) => entry.length > 0);
}
if (typeof value === 'string') {
const normalized = value.trim();
return normalized ? [normalized] : [];
}
return [];
}
function normalizeRows(rows: Record<string, unknown>[], columns: TableFieldColumn[]): Record<string, unknown>[] {
return (rows ?? []).map((row) => {
const normalized: Record<string, unknown> = { ...row };
@@ -108,6 +125,19 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
rows.forEach((row, rowIndex) => {
columns.forEach((col) => {
if (col.type === 'multiselect') {
const filteredOptions = getFilteredSelectOptions(col, row);
const validValues = new Set(filteredOptions.map((opt) => opt.value));
const currentValues = normalizeMultiValue(row[col.key]);
const normalizedValues = currentValues.filter((entry) => validValues.has(entry));
if (JSON.stringify(currentValues) !== JSON.stringify(normalizedValues)) {
nextRows[rowIndex][col.key] = normalizedValues;
hasChanges = true;
}
return;
}
if (col.type !== 'select') return;
const filteredOptions = getFilteredSelectOptions(col, row);
@@ -146,7 +176,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
disabled={isDisabled}
className="px-3 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors
hover-action transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
>
{field.addLabel || 'Add'}
@@ -223,6 +253,44 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
);
}
if (col.type === 'multiselect') {
const options = getFilteredSelectOptions(col, row).map((opt) => ({
value: opt.value,
label: opt.label,
description: opt.description,
childOf: opt.childOf,
}));
const selectedValues = normalizeMultiValue(cellValue).filter((entry) =>
options.some((option) => option.value === entry)
);
const multiSelectField: MultiSelectFieldConfig = {
type: 'MultiSelectField',
key: `${field.key}_${rowIndex}_${col.key}`,
label: col.label,
value: selectedValues,
options,
variant: 'dropdown',
placeholder: col.placeholder || 'Select...',
};
return (
<div key={col.key} className="flex flex-col gap-1 min-w-0">
{mobileLabel}
<MultiSelectField
field={multiSelectField}
value={selectedValues}
onChange={(nextValues) => {
const normalizedValues = (nextValues ?? [])
.map((entry) => String(entry ?? '').trim())
.filter((entry) => entry.length > 0);
updateCell(rowIndex, col.key, normalizedValues);
}}
disabled={isDisabled}
/>
</div>
);
}
// text/path
return (
<div key={col.key} className="flex flex-col gap-1 min-w-0">
@@ -248,7 +316,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
type="button"
onClick={() => removeRow(rowIndex)}
disabled={isDisabled}
className="p-1.5 rounded-full hover:bg-[var(--hover-surface)]
className="p-1.5 rounded-full hover-action
disabled:opacity-60 disabled:cursor-not-allowed"
aria-label="Remove row"
>
@@ -276,7 +344,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
disabled={isDisabled}
className="px-3 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors
hover-action transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
>
{field.addLabel || 'Add'}
@@ -82,15 +82,17 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
return (
<div
className={`w-full px-2 py-1 rounded-lg border border-[var(--border-muted)]
className={`w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus-within:outline-none focus-within:ring-2 focus-within:ring-sky-500/50 focus-within:border-sky-500
transition-colors
${isDisabled ? 'opacity-60 cursor-not-allowed' : 'cursor-text'}`}
onClick={() => {
if (isDisabled) return;
inputRef.current?.focus();
}}
>
<div className="flex flex-wrap gap-1 items-center">
<div className="flex flex-wrap gap-1 items-center min-h-[1.25rem]">
{tags.map((tag, idx) => (
<span
key={`${tag}-${idx}`}
@@ -144,7 +146,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
}}
onBlur={() => commitDraft()}
placeholder={tags.length === 0 ? field.placeholder : ''}
className="flex-1 min-w-[4rem] bg-transparent outline-none px-1 py-0.5"
className="flex-1 min-w-[4rem] bg-transparent outline-none px-1 py-0"
/>
)}
@@ -3,12 +3,68 @@ import { SettingsField } from '../../../types/settings';
import { Tooltip } from '../../shared/Tooltip';
import { EnvLockBadge } from './EnvLockBadge';
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
const renderDescriptionWithLinks = (description: string): ReactNode => {
const parts: ReactNode[] = [];
let lastIndex = 0;
MARKDOWN_LINK_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null = MARKDOWN_LINK_PATTERN.exec(description);
while (match) {
const [fullMatch, label, url] = match;
const matchIndex = match.index;
if (matchIndex > lastIndex) {
parts.push(
<span key={`text-${lastIndex}-${matchIndex}`} className="opacity-60">
{description.slice(lastIndex, matchIndex)}
</span>
);
}
parts.push(
<a
key={`${url}-${matchIndex}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="underline text-sky-600 dark:text-sky-400"
>
{label}
</a>
);
lastIndex = matchIndex + fullMatch.length;
match = MARKDOWN_LINK_PATTERN.exec(description);
}
if (lastIndex < description.length) {
parts.push(
<span key={`text-${lastIndex}-end`} className="opacity-60">
{description.slice(lastIndex)}
</span>
);
}
if (parts.length === 0) {
return <span className="opacity-60">{description}</span>;
}
return parts;
};
interface FieldWrapperProps {
field: SettingsField;
children: ReactNode;
// Optional overrides for dynamic disabled state (from disabledWhen)
disabledOverride?: boolean;
disabledReasonOverride?: string;
resetAction?: {
label?: string;
disabled?: boolean;
onClick: () => void;
};
headerRight?: ReactNode;
userOverrideCount?: number;
userOverrideDetails?: Array<{
@@ -114,18 +170,38 @@ const UserOverriddenBadge = ({
);
};
const ResetActionButton = ({
label = 'Reset',
disabled = false,
onClick,
}: {
label?: string;
disabled?: boolean;
onClick: () => void;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="text-xs font-medium text-sky-500 hover:text-sky-400 transition-colors shrink-0
disabled:opacity-50 disabled:cursor-not-allowed"
>
{label}
</button>
);
export const FieldWrapper = ({
field,
children,
disabledOverride,
disabledReasonOverride,
resetAction,
headerRight,
userOverrideCount,
userOverrideDetails,
}: FieldWrapperProps) => {
// Action buttons, headings, and table fields handle their own layout
// Table fields have column headers, so they don't need a separate label
if (field.type === 'ActionButton' || field.type === 'HeadingField' || field.type === 'TableField') {
// Action buttons and headings handle their own layout
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
return <>{children}</>;
}
@@ -134,35 +210,55 @@ export const FieldWrapper = ({
const isDisabled = disabledOverride ?? field.disabled;
const disabledReason = disabledReasonOverride ?? field.disabledReason;
const requiresRestart = field.requiresRestart;
const hasUserOverrides = Boolean(userOverrideCount) && (userOverrideCount || 0) > 0;
const hasLabel = Boolean(field.label && field.label.trim().length > 0);
const hasResetAction = Boolean(resetAction);
const showHeaderLeft = hasLabel || field.fromEnv || (requiresRestart && !isDisabled && !field.fromEnv)
|| (isDisabled && !field.fromEnv) || hasUserOverrides;
const showHeader = showHeaderLeft || hasResetAction || Boolean(headerRight);
// ENV-locked fields should only dim the control, not the label/description
const isFullyDimmed = isDisabled && !field.fromEnv;
return (
<div className="space-y-1.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<label className={`text-sm font-medium ${isFullyDimmed ? 'text-zinc-500' : ''}`}>
{field.label}
{field.required && !isDisabled && <span className="text-red-500 ml-0.5">*</span>}
</label>
{field.fromEnv && <EnvLockBadge />}
{requiresRestart && !isDisabled && !field.fromEnv && <RestartRequiredBadge />}
{isDisabled && !field.fromEnv && <DisabledBadge reason={disabledReason} />}
{Boolean(userOverrideCount) && (userOverrideCount || 0) > 0 && (
<UserOverriddenBadge
count={userOverrideCount || 0}
details={userOverrideDetails}
/>
{showHeader && (
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap min-w-0">
{hasLabel && (
<label className={`text-sm font-medium ${isFullyDimmed ? 'text-zinc-500' : ''}`}>
{field.label}
{field.required && !isDisabled && <span className="text-red-500 ml-0.5">*</span>}
</label>
)}
{field.fromEnv && <EnvLockBadge />}
{requiresRestart && !isDisabled && !field.fromEnv && <RestartRequiredBadge />}
{isDisabled && !field.fromEnv && <DisabledBadge reason={disabledReason} />}
{hasUserOverrides && (
<UserOverriddenBadge
count={userOverrideCount || 0}
details={userOverrideDetails}
/>
)}
</div>
{(hasResetAction || headerRight) && (
<div className="flex items-center gap-2 shrink-0">
{hasResetAction && resetAction && (
<ResetActionButton
label={resetAction.label}
disabled={resetAction.disabled}
onClick={resetAction.onClick}
/>
)}
{headerRight}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">{headerRight}</div>
</div>
)}
<div className={isFullyDimmed ? 'opacity-50' : ''}>{children}</div>
{field.description && (
<p className="text-xs opacity-60">{field.description}</p>
<p className="text-xs">{renderDescriptionWithLinks(field.description)}</p>
)}
{isDisabled && disabledReason && (
@@ -76,8 +76,6 @@ export const UserListView = ({
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode);
const isCwaMode = String(authMode || 'none').toLowerCase() === 'cwa';
const toggleButtonClasses = 'p-2 rounded-full hover-action transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100';
const handleDelete = async (userId: number) => {
const ok = await onDelete(userId);
if (ok) {
@@ -121,7 +119,32 @@ export const UserListView = ({
className={`rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] transition-colors ${active ? '' : 'opacity-60'}`}
>
<div
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 ${isEditingRow ? 'border-b border-[var(--border-muted)]' : ''}`}
role="button"
tabIndex={0}
onClick={(e) => {
// Don't toggle when clicking interactive elements inside the header (e.g. role dropdown)
if ((e.target as HTMLElement).closest('button:not([data-card-toggle]), [role="listbox"], [data-dropdown]')) return;
setConfirmDelete(null);
if (isEditingRow) {
onCancelEdit();
} else {
onEdit(user);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setConfirmDelete(null);
if (isEditingRow) {
onCancelEdit();
} else {
onEdit(user);
}
}
}}
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 cursor-pointer hover-surface rounded-t-lg ${isEditingRow ? 'border-b border-[var(--border-muted)]' : 'rounded-b-lg'}`}
aria-expanded={isEditingRow}
aria-label={isEditingRow ? 'Collapse user editor' : `Expand ${user.username} editor`}
>
<UserIdentityHeader user={user} />
@@ -137,18 +160,9 @@ export const UserListView = ({
<UserRoleControl user={user} />
)}
<button
onClick={() => {
setConfirmDelete(null);
if (isEditingRow) {
onCancelEdit();
} else {
onEdit(user);
}
}}
className={toggleButtonClasses}
aria-label={isEditingRow ? 'Collapse user editor' : `Expand ${user.username} editor`}
title={isEditingRow ? 'Collapse editor' : 'Expand editor'}
<div
className="p-2 rounded-full text-gray-500 dark:text-gray-400"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -164,7 +178,7 @@ export const UserListView = ({
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
</div>
</div>
</div>
@@ -0,0 +1,228 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import {
ActionButtonConfig,
ActionResult,
HeadingFieldConfig,
TableFieldConfig,
} from '../../../types/settings';
import { ActionButton, HeadingField, TableField } from '../fields';
import { FieldWrapper } from '../shared';
import { getFieldByKey } from './fieldHelpers';
import { PerUserSettings } from './types';
interface UserNotificationOverridesSectionProps {
notificationPreferences: DeliveryPreferencesResponse | null;
isUserOverridable: (key: keyof PerUserSettings) => boolean;
userSettings: PerUserSettings;
setUserSettings: (updater: (prev: PerUserSettings) => PerUserSettings) => void;
onTestNotificationRoutes?: (routes: Array<Record<string, unknown>>) => Promise<ActionResult>;
}
type NotificationSettingKey = 'USER_NOTIFICATION_ROUTES';
const ROUTE_EVENT_ALL = 'all';
const USER_ROUTE_EVENT_OPTIONS = [
{ value: ROUTE_EVENT_ALL, label: 'All' },
{ value: 'request_created', label: 'New request submitted' },
{ value: 'request_fulfilled', label: 'Request approved' },
{ value: 'request_rejected', label: 'Request rejected' },
{ value: 'download_complete', label: 'Download complete' },
{ value: 'download_failed', label: 'Download failed' },
];
const ALLOWED_ROUTE_EVENTS = new Set(USER_ROUTE_EVENT_OPTIONS.map((option) => option.value));
const ROUTE_EVENT_ORDER = new Map(USER_ROUTE_EVENT_OPTIONS.map((option, index) => [option.value, index]));
const fallbackRoutesField: TableFieldConfig = {
type: 'TableField',
key: 'USER_NOTIFICATION_ROUTES',
label: '',
description: (
'Create one route per URL. Start with All, then add event-specific routes '
+ 'for targeted delivery. Need format examples? '
+ '[View Apprise URL formats](https://appriseit.com/services/).'
),
value: [{ event: [ROUTE_EVENT_ALL], url: '' }],
columns: [
{
key: 'event',
label: 'Event',
type: 'multiselect',
options: USER_ROUTE_EVENT_OPTIONS,
defaultValue: [ROUTE_EVENT_ALL],
placeholder: 'Select events...',
},
{
key: 'url',
label: 'Notification URL',
type: 'text',
placeholder: 'e.g. ntfys://ntfy.sh/username-topic',
},
],
addLabel: 'Add Route',
emptyMessage: 'No routes configured.',
};
const notificationHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'notification_preferences_heading',
title: 'Notifications',
description: 'Personal notification preferences for this user. Reset to inherit global defaults from the Notifications tab.',
};
const testNotificationActionField: ActionButtonConfig = {
type: 'ActionButton',
key: 'test_user_notification',
label: 'Test Notification',
description: 'Send a test notification to the configured personal route URLs.',
style: 'primary',
};
function normalizeRoutesValue(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) {
return [{ event: [ROUTE_EVENT_ALL], url: '' }];
}
const normalized: Array<Record<string, unknown>> = [];
const seen = new Set<string>();
const normalizeRouteEvents = (rawEventValue: unknown): string[] => {
const rawValues = Array.isArray(rawEventValue)
? rawEventValue
: (rawEventValue === undefined || rawEventValue === null ? [] : [rawEventValue]);
const deduped = new Set<string>();
rawValues.forEach((rawEvent) => {
const event = String(rawEvent ?? '').trim().toLowerCase();
if (!ALLOWED_ROUTE_EVENTS.has(event)) {
return;
}
deduped.add(event);
});
if (deduped.has(ROUTE_EVENT_ALL)) {
return [ROUTE_EVENT_ALL];
}
return Array.from(deduped).sort((a, b) => {
return (ROUTE_EVENT_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER)
- (ROUTE_EVENT_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER);
});
};
value.forEach((row) => {
if (!row || typeof row !== 'object') {
return;
}
const events = normalizeRouteEvents((row as Record<string, unknown>).event);
if (events.length === 0) {
return;
}
const url = String((row as Record<string, unknown>).url ?? '').trim();
const key = `${events.join('|')}::${url}`;
if (seen.has(key)) {
return;
}
seen.add(key);
normalized.push({ event: events, url });
});
return normalized.length > 0 ? normalized : [{ event: [ROUTE_EVENT_ALL], url: '' }];
}
export const UserNotificationOverridesSection = ({
notificationPreferences,
isUserOverridable,
userSettings,
setUserSettings,
onTestNotificationRoutes,
}: UserNotificationOverridesSectionProps) => {
if (!notificationPreferences) {
return null;
}
const fields = notificationPreferences.fields ?? [];
const globalValues = notificationPreferences.globalValues ?? {};
const routesField = getFieldByKey<TableFieldConfig>(
fields,
'USER_NOTIFICATION_ROUTES',
fallbackRoutesField
);
const isOverridden = (key: NotificationSettingKey): boolean => {
if (
!Object.prototype.hasOwnProperty.call(userSettings, key)
|| userSettings[key] === null
|| userSettings[key] === undefined
) {
return false;
}
return JSON.stringify(normalizeRoutesValue(userSettings[key]))
!== JSON.stringify(normalizeRoutesValue(globalValues[key]));
};
const resetKeys = (keys: NotificationSettingKey[]) => {
setUserSettings((prev) => {
const next = { ...prev };
keys.forEach((key) => {
delete next[key];
});
return next;
});
};
const readRoutesValue = (key: NotificationSettingKey): Array<Record<string, unknown>> => {
if (isOverridden(key)) {
return normalizeRoutesValue(userSettings[key]);
}
if (Object.prototype.hasOwnProperty.call(globalValues, key)) {
return normalizeRoutesValue(globalValues[key]);
}
return normalizeRoutesValue([]);
};
const routesValue = readRoutesValue('USER_NOTIFICATION_ROUTES');
const canOverrideRoutes = isUserOverridable('USER_NOTIFICATION_ROUTES');
if (!canOverrideRoutes) {
return null;
}
return (
<div className="space-y-4">
<HeadingField field={notificationHeading} />
<FieldWrapper
field={routesField}
resetAction={
isOverridden('USER_NOTIFICATION_ROUTES') ? (
{
disabled: Boolean(routesField.fromEnv),
onClick: () => resetKeys(['USER_NOTIFICATION_ROUTES']),
}
) : undefined
}
>
<TableField
field={routesField}
value={routesValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, USER_NOTIFICATION_ROUTES: value }))}
disabled={Boolean(routesField.fromEnv)}
/>
</FieldWrapper>
{onTestNotificationRoutes && (
<ActionButton
field={testNotificationActionField}
onAction={() => onTestNotificationRoutes(routesValue)}
disabled={Boolean(routesField.fromEnv)}
/>
)}
</div>
);
};
@@ -2,11 +2,11 @@ import { DeliveryPreferencesResponse } from '../../../services/api';
import {
HeadingFieldConfig,
SelectFieldConfig,
SettingsField,
TextFieldConfig,
} from '../../../types/settings';
import { HeadingField, SelectField, TextField } from '../fields';
import { FieldWrapper } from '../shared';
import { getFieldByKey } from './fieldHelpers';
import { PerUserSettings } from './types';
interface UserOverridesSectionProps {
@@ -91,37 +91,6 @@ function toStringValue(value: unknown): string {
return String(value);
}
function getFieldByKey<T extends SettingsField>(
fields: SettingsField[] | undefined,
key: string,
fallback: T
): T {
const found = fields?.find((field) => field.key === key);
if (!found) {
return fallback;
}
return found as T;
}
interface ResetOverrideButtonProps {
disabled?: boolean;
label?: string;
onClick: () => void;
}
const ResetOverrideButton = ({ disabled = false, label = 'Reset', onClick }: ResetOverrideButtonProps) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="px-2.5 py-1 rounded-lg text-xs font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
{label}
</button>
);
const deliveryHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'delivery_preferences_heading',
@@ -243,12 +212,12 @@ export const UserOverridesSection = ({
{canOverrideOutputMode && (
<FieldWrapper
field={outputModeField}
headerRight={
resetAction={
hasBookDeliveryOverride ? (
<ResetOverrideButton
label="Reset all"
onClick={() => resetKeys(availableBookPreferenceKeys)}
/>
{
label: 'Reset all',
onClick: () => resetKeys(availableBookPreferenceKeys),
}
) : undefined
}
>
@@ -264,13 +233,13 @@ export const UserOverridesSection = ({
{effectiveOutputMode === 'folder' && canOverrideDestination && (
<FieldWrapper
field={destinationField}
headerRight={
isOverridden('DESTINATION') ? (
<ResetOverrideButton
disabled={Boolean(destinationField.fromEnv)}
onClick={() => resetKeys(['DESTINATION'])}
/>
) : undefined
resetAction={
isOverridden('DESTINATION')
? {
disabled: Boolean(destinationField.fromEnv),
onClick: () => resetKeys(['DESTINATION']),
}
: undefined
}
>
<TextField
@@ -285,13 +254,13 @@ export const UserOverridesSection = ({
{effectiveOutputMode === 'booklore' && canOverrideBookloreLibrary && (
<FieldWrapper
field={bookloreLibraryField}
headerRight={
isOverridden('BOOKLORE_LIBRARY_ID') ? (
<ResetOverrideButton
disabled={Boolean(bookloreLibraryField.fromEnv)}
onClick={() => resetKeys(['BOOKLORE_LIBRARY_ID'])}
/>
) : undefined
resetAction={
isOverridden('BOOKLORE_LIBRARY_ID')
? {
disabled: Boolean(bookloreLibraryField.fromEnv),
onClick: () => resetKeys(['BOOKLORE_LIBRARY_ID']),
}
: undefined
}
>
<SelectField
@@ -312,13 +281,13 @@ export const UserOverridesSection = ({
{effectiveOutputMode === 'booklore' && canOverrideBooklorePath && (
<FieldWrapper
field={booklorePathField}
headerRight={
isOverridden('BOOKLORE_PATH_ID') ? (
<ResetOverrideButton
disabled={Boolean(booklorePathField.fromEnv)}
onClick={() => resetKeys(['BOOKLORE_PATH_ID'])}
/>
) : undefined
resetAction={
isOverridden('BOOKLORE_PATH_ID')
? {
disabled: Boolean(booklorePathField.fromEnv),
onClick: () => resetKeys(['BOOKLORE_PATH_ID']),
}
: undefined
}
>
<SelectField
@@ -334,13 +303,13 @@ export const UserOverridesSection = ({
{effectiveOutputMode === 'email' && canOverrideEmailRecipient && (
<FieldWrapper
field={emailRecipientField}
headerRight={
isOverridden('EMAIL_RECIPIENT') ? (
<ResetOverrideButton
disabled={Boolean(emailRecipientField.fromEnv)}
onClick={() => resetKeys(['EMAIL_RECIPIENT'])}
/>
) : undefined
resetAction={
isOverridden('EMAIL_RECIPIENT')
? {
disabled: Boolean(emailRecipientField.fromEnv),
onClick: () => resetKeys(['EMAIL_RECIPIENT']),
}
: undefined
}
>
<TextField
@@ -357,13 +326,13 @@ export const UserOverridesSection = ({
<HeadingField field={audiobooksHeading} />
<FieldWrapper
field={destinationAudiobookField}
headerRight={
hasAudiobookDeliveryOverride ? (
<ResetOverrideButton
disabled={Boolean(destinationAudiobookField.fromEnv)}
onClick={() => resetKeys(availableAudiobookPreferenceKeys)}
/>
) : undefined
resetAction={
hasAudiobookDeliveryOverride
? {
disabled: Boolean(destinationAudiobookField.fromEnv),
onClick: () => resetKeys(availableAudiobookPreferenceKeys),
}
: undefined
}
>
<TextField
@@ -0,0 +1,143 @@
import { Fragment, ReactElement } from 'react';
import { DeliveryPreferencesResponse } from '../../../services/api';
import { ActionResult } from '../../../types/settings';
import { SettingsTab } from '../../../types/settings';
import { UserNotificationOverridesSection } from './UserNotificationOverridesSection';
import { UserOverridesSection } from './UserOverridesSection';
import { UserRequestPolicyOverridesSection } from './UserRequestPolicyOverridesSection';
import { PerUserSettings } from './types';
export type UserOverrideScope = 'admin' | 'self';
export type UserOverrideSectionId = 'delivery' | 'notifications' | 'requestPolicy';
interface UserOverridesSectionsProps {
scope: UserOverrideScope;
sections?: UserOverrideSectionId[];
deliveryPreferences: DeliveryPreferencesResponse | null;
notificationPreferences: DeliveryPreferencesResponse | null;
isUserOverridable: (key: keyof PerUserSettings) => boolean;
userSettings: PerUserSettings;
setUserSettings: (updater: (prev: PerUserSettings) => PerUserSettings) => void;
usersTab?: SettingsTab;
globalUsersSettingsValues?: Record<string, unknown>;
onTestNotificationRoutes?: (routes: Array<Record<string, unknown>>) => Promise<ActionResult>;
}
interface UserOverrideSectionDefinition {
id: UserOverrideSectionId;
adminOnly: boolean;
}
interface UserOverrideSectionNode {
id: UserOverrideSectionId;
node: ReactElement;
}
const USER_OVERRIDE_SECTION_DEFINITIONS: UserOverrideSectionDefinition[] = [
{ id: 'delivery', adminOnly: false },
{ id: 'notifications', adminOnly: false },
{ id: 'requestPolicy', adminOnly: true },
];
const USER_OVERRIDE_SECTION_ORDER: UserOverrideSectionId[] =
USER_OVERRIDE_SECTION_DEFINITIONS.map((section) => section.id);
const USER_OVERRIDE_SECTION_META: Record<UserOverrideSectionId, UserOverrideSectionDefinition> = {
delivery: { id: 'delivery', adminOnly: false },
notifications: { id: 'notifications', adminOnly: false },
requestPolicy: { id: 'requestPolicy', adminOnly: true },
};
export const UserOverridesSections = ({
scope,
sections,
deliveryPreferences,
notificationPreferences,
isUserOverridable,
userSettings,
setUserSettings,
usersTab,
globalUsersSettingsValues,
onTestNotificationRoutes,
}: UserOverridesSectionsProps) => {
const requestedSections = sections ?? USER_OVERRIDE_SECTION_ORDER;
const activeSections = requestedSections.filter((sectionId) => {
if (scope === 'self' && USER_OVERRIDE_SECTION_META[sectionId].adminOnly) {
return false;
}
return true;
});
const sectionNodes: UserOverrideSectionNode[] = [];
activeSections.forEach((sectionId) => {
if (sectionId === 'delivery') {
if (!deliveryPreferences) {
return;
}
sectionNodes.push({
id: sectionId,
node: (
<UserOverridesSection
deliveryPreferences={deliveryPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={setUserSettings}
/>
),
});
return;
}
if (sectionId === 'notifications') {
if (!notificationPreferences) {
return;
}
sectionNodes.push({
id: sectionId,
node: (
<UserNotificationOverridesSection
notificationPreferences={notificationPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={setUserSettings}
onTestNotificationRoutes={onTestNotificationRoutes}
/>
),
});
return;
}
if (!usersTab || !globalUsersSettingsValues) {
return;
}
sectionNodes.push({
id: sectionId,
node: (
<UserRequestPolicyOverridesSection
usersTab={usersTab}
globalUsersSettingsValues={globalUsersSettingsValues}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={setUserSettings}
/>
),
});
});
if (sectionNodes.length === 0) {
return null;
}
return (
<div className="space-y-5">
{sectionNodes.map(({ id, node }, index) => (
<Fragment key={id}>
{index > 0 && <div className="border-t border-[var(--border-muted)]" />}
{node}
</Fragment>
))}
</div>
);
};
@@ -1,20 +1,21 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import { PerUserSettings } from './types';
import { SettingsSubpage } from '../shared';
import { UserOverridesSection } from './UserOverridesSection';
import { SettingsTab } from '../../../types/settings';
import { UserRequestPolicyOverridesSection } from './UserRequestPolicyOverridesSection';
import { ActionResult, SettingsTab } from '../../../types/settings';
import { UserOverridesSections } from './UserOverridesSections';
interface UserOverridesViewProps {
embedded?: boolean;
hasChanges: boolean;
onBack: () => void;
deliveryPreferences: DeliveryPreferencesResponse | null;
notificationPreferences: DeliveryPreferencesResponse | null;
isUserOverridable: (key: keyof PerUserSettings) => boolean;
userSettings: PerUserSettings;
setUserSettings: (updater: (prev: PerUserSettings) => PerUserSettings) => void;
usersTab: SettingsTab;
globalUsersSettingsValues: Record<string, unknown>;
onTestNotificationRoutes?: (routes: Array<Record<string, unknown>>) => Promise<ActionResult>;
}
export const UserOverridesView = ({
@@ -22,11 +23,13 @@ export const UserOverridesView = ({
hasChanges,
onBack,
deliveryPreferences,
notificationPreferences,
isUserOverridable,
userSettings,
setUserSettings,
usersTab,
globalUsersSettingsValues,
onTestNotificationRoutes,
}: UserOverridesViewProps) => {
const content = (
<div className="space-y-5">
@@ -50,19 +53,16 @@ export const UserOverridesView = ({
</button>
</div>
<UserOverridesSection
<UserOverridesSections
scope="admin"
deliveryPreferences={deliveryPreferences}
notificationPreferences={notificationPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={setUserSettings}
/>
<UserRequestPolicyOverridesSection
usersTab={usersTab}
globalUsersSettingsValues={globalUsersSettingsValues}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={setUserSettings}
onTestNotificationRoutes={onTestNotificationRoutes}
/>
</div>
);
@@ -146,7 +146,6 @@ export const UserRequestPolicyOverridesSection = ({
return (
<div className="space-y-3">
<div className="border-t border-[var(--border-muted)]" />
<HeadingField field={requestPolicyHeading} />
<RequestPolicyGrid
@@ -0,0 +1,13 @@
import { SettingsField } from '../../../types/settings';
export const getFieldByKey = <T extends SettingsField>(
fields: SettingsField[] | undefined,
key: string,
fallback: T
): T => {
const found = fields?.find((field) => field.key === key);
if (!found) {
return fallback;
}
return found as T;
};
@@ -9,10 +9,13 @@ export {
} from './UserCard';
export { UserListView } from './UserListView';
export { RequestPolicyGrid } from './RequestPolicyGrid';
export { UserNotificationOverridesSection } from './UserNotificationOverridesSection';
export { UserOverridesSection } from './UserOverridesSection';
export { UserOverridesSections } from './UserOverridesSections';
export { UserOverridesView } from './UserOverridesView';
export { useUserForm } from './useUserForm';
export { useUserMutations } from './useUserMutations';
export { useUserOverridesState } from './useUserOverridesState';
export { useUsersFetch } from './useUsersFetch';
export { useUsersPanelState } from './useUsersPanelState';
export { canCreateLocalUsersForAuthMode, getUsersHeadingDescriptionForAuthMode } from './types';
@@ -5,20 +5,25 @@ const normalizeComparableValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
};
export const buildUserSettingsPayload = (
userSettings: PerUserSettings,
userOverridableSettings: Set<string>,
deliveryPreferences: DeliveryPreferencesResponse | null,
preferenceGroups: Array<DeliveryPreferencesResponse | null>,
): Record<string, unknown> =>
Array.from(
new Set([
...(deliveryPreferences?.keys || []),
...userOverridableSettings,
])
)
Array.from(new Set([
...preferenceGroups.flatMap((preferences) => preferences?.keys || []),
...userOverridableSettings,
]))
.map(String)
.sort()
.reduce<Record<string, unknown>>((payload, key) => {
@@ -33,8 +38,15 @@ export const buildUserSettingsPayload = (
}
const userValue = userSettings[typedKey];
const globalValue = deliveryPreferences?.globalValues?.[key];
const isDifferentFromGlobal = deliveryPreferences
const matchingPreferences = preferenceGroups.find((preferences) =>
preferences?.keys?.includes(key)
);
const hasGlobalValue = Boolean(
matchingPreferences
&& Object.prototype.hasOwnProperty.call(matchingPreferences.globalValues, key)
);
const globalValue = matchingPreferences?.globalValues?.[key];
const isDifferentFromGlobal = hasGlobalValue
? normalizeComparableValue(userValue) !== normalizeComparableValue(globalValue)
: true;
@@ -8,6 +8,7 @@ export interface PerUserSettings {
BOOKLORE_LIBRARY_ID?: string;
BOOKLORE_PATH_ID?: string;
EMAIL_RECIPIENT?: string;
USER_NOTIFICATION_ROUTES?: Array<Record<string, unknown>>;
REQUESTS_ENABLED?: boolean;
REQUEST_POLICY_DEFAULT_EBOOK?: string;
REQUEST_POLICY_DEFAULT_AUDIOBOOK?: string;
@@ -1,20 +1,8 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { AdminUser, DeliveryPreferencesResponse, DownloadDefaults } from '../../../services/api';
import { CreateUserFormState, INITIAL_CREATE_FORM, PerUserSettings } from './types';
import { CreateUserFormState, INITIAL_CREATE_FORM } from './types';
import { UserEditContext } from './useUsersFetch';
import { buildUserSettingsPayload } from './settingsPayload';
const normalizeUserSettings = (settings: PerUserSettings): PerUserSettings => {
const normalized: PerUserSettings = {};
Object.keys(settings).sort().forEach((key) => {
const typedKey = key as keyof PerUserSettings;
const value = settings[typedKey];
if (value !== null && value !== undefined) {
normalized[typedKey] = value;
}
});
return normalized;
};
import { useUserOverridesState } from './useUserOverridesState';
export const useUserForm = () => {
const [createForm, setCreateForm] = useState<CreateUserFormState>({ ...INITIAL_CREATE_FORM });
@@ -23,18 +11,28 @@ export const useUserForm = () => {
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
const [downloadDefaults, setDownloadDefaults] = useState<DownloadDefaults | null>(null);
const [deliveryPreferences, setDeliveryPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [originalUserSettings, setOriginalUserSettings] = useState<PerUserSettings>({});
const [userOverridableSettings, setUserOverridableSettings] = useState<Set<string>>(new Set());
const [notificationPreferences, setNotificationPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const preferenceGroups = useMemo(
() => [deliveryPreferences, notificationPreferences],
[deliveryPreferences, notificationPreferences]
);
const {
userSettings,
setUserSettings,
userOverridableSettings,
isUserOverridable,
hasUserSettingsChanges,
applyUserOverridesContext,
resetUserOverridesState,
} = useUserOverridesState({ preferenceGroups });
const resetCreateForm = () => setCreateForm({ ...INITIAL_CREATE_FORM });
const resetEditContext = () => {
setDownloadDefaults(null);
setDeliveryPreferences(null);
setUserSettings({});
setOriginalUserSettings({});
setUserOverridableSettings(new Set());
setNotificationPreferences(null);
resetUserOverridesState();
};
const beginEditing = (user: AdminUser) => {
@@ -44,13 +42,14 @@ export const useUserForm = () => {
};
const applyUserEditContext = (context: UserEditContext) => {
const normalizedSettings = normalizeUserSettings(context.userSettings);
setEditingUser({ ...context.user });
setDownloadDefaults(context.downloadDefaults);
setDeliveryPreferences(context.deliveryPreferences);
setUserSettings(normalizedSettings);
setOriginalUserSettings(normalizedSettings);
setUserOverridableSettings(new Set(context.userOverridableSettings));
setNotificationPreferences(context.notificationPreferences);
applyUserOverridesContext({
settings: context.userSettings,
userOverridableKeys: context.userOverridableSettings,
});
};
const clearEditState = () => {
@@ -60,11 +59,6 @@ export const useUserForm = () => {
resetEditContext();
};
const isUserOverridable = (key: keyof PerUserSettings) => userOverridableSettings.has(String(key));
const hasUserSettingsChanges =
JSON.stringify(buildUserSettingsPayload(userSettings, userOverridableSettings, deliveryPreferences))
!== JSON.stringify(buildUserSettingsPayload(originalUserSettings, userOverridableSettings, deliveryPreferences));
return {
createForm,
setCreateForm,
@@ -81,6 +75,7 @@ export const useUserForm = () => {
setEditPasswordConfirm,
downloadDefaults,
deliveryPreferences,
notificationPreferences,
userSettings,
setUserSettings,
hasUserSettingsChanges,
@@ -23,6 +23,7 @@ interface UseUserMutationsParams {
userSettings: PerUserSettings;
userOverridableSettings: Set<string>;
deliveryPreferences: DeliveryPreferencesResponse | null;
notificationPreferences: DeliveryPreferencesResponse | null;
onEditSaveSuccess?: () => void;
}
@@ -60,6 +61,7 @@ export const useUserMutations = ({
userSettings,
userOverridableSettings,
deliveryPreferences,
notificationPreferences,
onEditSaveSuccess,
}: UseUserMutationsParams) => {
const [creating, setCreating] = useState(false);
@@ -105,7 +107,11 @@ export const useUserMutations = ({
const caps = editingUser.edit_capabilities;
const settingsPayload = includeSettings
? buildUserSettingsPayload(userSettings, userOverridableSettings, deliveryPreferences)
? buildUserSettingsPayload(
userSettings,
userOverridableSettings,
[deliveryPreferences, notificationPreferences]
)
: null;
const updatePayload: Partial<Pick<AdminUser, 'role' | 'email' | 'display_name'>> & {
password?: string;
@@ -0,0 +1,87 @@
import { useCallback, useMemo, useState } from 'react';
import { DeliveryPreferencesResponse } from '../../../services/api';
import { buildUserSettingsPayload } from './settingsPayload';
import { PerUserSettings } from './types';
interface UseUserOverridesStateParams {
preferenceGroups: Array<DeliveryPreferencesResponse | null>;
}
interface ApplyUserOverridesContextParams {
settings: PerUserSettings;
userOverridableKeys: Iterable<string>;
}
const normalizeUserSettings = (settings: PerUserSettings): PerUserSettings => {
const normalized: PerUserSettings = {};
Object.keys(settings).sort().forEach((key) => {
const typedKey = key as keyof PerUserSettings;
const value = settings[typedKey];
if (value !== null && value !== undefined) {
normalized[typedKey] = value;
}
});
return normalized;
};
export const useUserOverridesState = ({
preferenceGroups,
}: UseUserOverridesStateParams) => {
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [originalUserSettings, setOriginalUserSettings] = useState<PerUserSettings>({});
const [userOverridableSettings, setUserOverridableSettings] = useState<Set<string>>(new Set());
const applyUserOverridesContext = useCallback(({
settings,
userOverridableKeys,
}: ApplyUserOverridesContextParams) => {
const normalizedSettings = normalizeUserSettings(settings);
setUserSettings(normalizedSettings);
setOriginalUserSettings(normalizedSettings);
setUserOverridableSettings(new Set(userOverridableKeys));
}, []);
const resetUserOverridesState = useCallback(() => {
setUserSettings({});
setOriginalUserSettings({});
setUserOverridableSettings(new Set());
}, []);
const isUserOverridable = useCallback(
(key: keyof PerUserSettings) => userOverridableSettings.has(String(key)),
[userOverridableSettings]
);
const currentSettingsPayload = useMemo(
() => buildUserSettingsPayload(
userSettings,
userOverridableSettings,
preferenceGroups
),
[preferenceGroups, userOverridableSettings, userSettings]
);
const originalSettingsPayload = useMemo(
() => buildUserSettingsPayload(
originalUserSettings,
userOverridableSettings,
preferenceGroups
),
[originalUserSettings, preferenceGroups, userOverridableSettings]
);
const hasUserSettingsChanges =
JSON.stringify(currentSettingsPayload) !== JSON.stringify(originalSettingsPayload);
return {
userSettings,
setUserSettings,
userOverridableSettings,
setUserOverridableSettings,
isUserOverridable,
currentSettingsPayload,
hasUserSettingsChanges,
applyUserOverridesContext,
resetUserOverridesState,
};
};
@@ -4,6 +4,7 @@ import {
DeliveryPreferencesResponse,
DownloadDefaults,
getAdminDeliveryPreferences,
getAdminNotificationPreferences,
getAdminUser,
getAdminUsers,
getDownloadDefaults,
@@ -59,6 +60,7 @@ export interface UserEditContext {
user: AdminUser;
downloadDefaults: DownloadDefaults;
deliveryPreferences: DeliveryPreferencesResponse | null;
notificationPreferences: DeliveryPreferencesResponse | null;
userSettings: PerUserSettings;
userOverridableSettings: Set<string>;
}
@@ -130,16 +132,33 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
]);
let deliveryPreferences: DeliveryPreferencesResponse | null = null;
let userSettings = (fullUser.settings || {}) as PerUserSettings;
let notificationPreferences: DeliveryPreferencesResponse | null = null;
let userSettings = {
...(fullUser.settings || {}),
} as PerUserSettings;
let userOverridableSettings = new Set<string>();
try {
const preferences = await getAdminDeliveryPreferences(userId);
deliveryPreferences = preferences;
userSettings = (preferences.userOverrides || fullUser.settings || {}) as PerUserSettings;
userOverridableSettings = new Set(preferences.keys || []);
} catch {
// Delivery preference introspection is best-effort.
const [deliveryResult, notificationResult] = await Promise.allSettled([
getAdminDeliveryPreferences(userId),
getAdminNotificationPreferences(userId),
]);
if (deliveryResult.status === 'fulfilled') {
deliveryPreferences = deliveryResult.value;
userSettings = {
...userSettings,
...(deliveryResult.value.userOverrides || {}),
} as PerUserSettings;
deliveryResult.value.keys.forEach((key) => userOverridableSettings.add(key));
}
if (notificationResult.status === 'fulfilled') {
notificationPreferences = notificationResult.value;
userSettings = {
...userSettings,
...(notificationResult.value.userOverrides || {}),
} as PerUserSettings;
notificationResult.value.keys.forEach((key) => userOverridableSettings.add(key));
}
try {
@@ -154,6 +173,7 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
user: fullUser,
downloadDefaults: defaults,
deliveryPreferences,
notificationPreferences,
userSettings,
userOverridableSettings,
};
+32
View File
@@ -598,6 +598,7 @@ export interface AdminUser {
export interface SelfUserEditContext {
user: AdminUser;
deliveryPreferences: DeliveryPreferencesResponse | null;
notificationPreferences: DeliveryPreferencesResponse | null;
userOverridableKeys: string[];
}
@@ -697,6 +698,37 @@ export const getAdminDeliveryPreferences = async (
return fetchJSON<DeliveryPreferencesResponse>(`${API_BASE}/admin/users/${userId}/delivery-preferences`);
};
export const getAdminNotificationPreferences = async (
userId: number
): Promise<DeliveryPreferencesResponse> => {
return fetchJSON<DeliveryPreferencesResponse>(`${API_BASE}/admin/users/${userId}/notification-preferences`);
};
export const testAdminUserNotificationPreferences = async (
userId: number,
routes: Array<Record<string, unknown>>
): Promise<import('../types/settings').ActionResult> => {
return fetchJSON<import('../types/settings').ActionResult>(
`${API_BASE}/admin/users/${userId}/notification-preferences/test`,
{
method: 'POST',
body: JSON.stringify({ USER_NOTIFICATION_ROUTES: routes }),
}
);
};
export const testSelfNotificationPreferences = async (
routes: Array<Record<string, unknown>>
): Promise<import('../types/settings').ActionResult> => {
return fetchJSON<import('../types/settings').ActionResult>(
`${API_BASE}/users/me/notification-preferences/test`,
{
method: 'POST',
body: JSON.stringify({ USER_NOTIFICATION_ROUTES: routes }),
}
);
};
export interface SettingsOverrideUserDetail {
userId: number;
username: string;
@@ -138,6 +138,20 @@ describe('activityMappers.requestToActivityItem', () => {
assert.equal(item.metaLine, 'Book request');
});
it('maps audiobook book-level request with audiobook label', () => {
const item = requestToActivityItem(
makeRequest({
request_level: 'book',
content_type: 'audiobook',
release_data: null,
source_hint: '*',
}),
'admin'
);
assert.equal(item.metaLine, 'Audiobook request · alice');
});
it('maps rejected requests with admin note', () => {
const item = requestToActivityItem(
makeRequest({
+3 -2
View File
@@ -95,6 +95,7 @@ export interface MultiSelectFieldConfig extends BaseField {
value: string[];
options: SelectOption[];
variant?: 'pills' | 'dropdown'; // 'pills' (default) or 'dropdown' for checkbox dropdown style
placeholder?: string;
}
export interface TagListFieldConfig extends BaseField {
@@ -145,7 +146,7 @@ export interface TableFieldColumnOption {
childOf?: string;
}
export type TableFieldColumnType = 'text' | 'select' | 'checkbox' | 'path';
export type TableFieldColumnType = 'text' | 'select' | 'multiselect' | 'checkbox' | 'path';
export interface TableFieldColumn {
key: string;
@@ -153,7 +154,7 @@ export interface TableFieldColumn {
type: TableFieldColumnType;
placeholder?: string;
options?: TableFieldColumnOption[];
defaultValue?: string | boolean;
defaultValue?: string | string[] | boolean;
filterByField?: string;
}
+45 -13
View File
@@ -1,3 +1,6 @@
import asyncio
def test_bypass_tries_all_methods_before_abort(monkeypatch):
"""Regression test for issue #524: don't abort before cycling through bypass methods."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
@@ -5,7 +8,7 @@ def test_bypass_tries_all_methods_before_abort(monkeypatch):
calls: list[str] = []
def _make_method(name: str):
def _method(_sb) -> bool:
async def _method(_sb) -> bool:
calls.append(name)
return False
@@ -14,13 +17,22 @@ def test_bypass_tries_all_methods_before_abort(monkeypatch):
methods = [_make_method(f"m{i}") for i in range(6)]
async def _always_false(*_args, **_kwargs) -> bool:
return False
async def _always_ddos_guard(*_args, **_kwargs) -> str:
return "ddos_guard"
async def _no_sleep(_seconds) -> None:
return None
monkeypatch.setattr(internal_bypasser, "BYPASS_METHODS", methods)
monkeypatch.setattr(internal_bypasser, "_is_bypassed", lambda _sb, escape_emojis=True: False)
monkeypatch.setattr(internal_bypasser, "_detect_challenge_type", lambda _sb: "ddos_guard")
monkeypatch.setattr(internal_bypasser.time, "sleep", lambda _seconds: None)
monkeypatch.setattr(internal_bypasser, "_is_bypassed", _always_false)
monkeypatch.setattr(internal_bypasser, "_detect_challenge_type", _always_ddos_guard)
monkeypatch.setattr(internal_bypasser.asyncio, "sleep", _no_sleep)
monkeypatch.setattr(internal_bypasser.random, "uniform", lambda _a, _b: 0)
assert internal_bypasser._bypass(object(), max_retries=10) is False
assert asyncio.run(internal_bypasser._bypass(object(), max_retries=10)) is False
assert calls == [f"m{i}" for i in range(6)]
@@ -37,19 +49,29 @@ def test_extract_cookies_from_cdp_filters_and_stores_ua():
self.expires = expires
self.secure = secure
class FakeSb:
def get_all_cookies(self, requests_cookie_format=False):
class FakeCookies:
async def get_all(self, requests_cookie_format=False):
assert requests_cookie_format is True
return [
FakeCookie("cf_clearance", "abc", "example.com", "/", int(time.time()) + 3600),
FakeCookie("sessionid", "zzz", "example.com", "/", int(time.time()) + 3600),
]
def get_user_agent(self):
class FakeDriver:
cookies = FakeCookies()
class FakePage:
async def evaluate(self, _expr):
return "TestUA/1.0"
internal_bypasser.clear_cf_cookies()
internal_bypasser._extract_cookies_from_cdp(FakeSb(), "https://www.example.com/path")
asyncio.run(
internal_bypasser._extract_cookies_from_cdp(
FakeDriver(),
FakePage(),
"https://www.example.com/path",
)
)
cookies = internal_bypasser.get_cf_cookies_for_domain("example.com")
assert cookies == {"cf_clearance": "abc"}
@@ -69,18 +91,28 @@ def test_extract_cookies_from_cdp_normalizes_session_expiry():
self.expires = expires
self.secure = secure
class FakeSb:
def get_all_cookies(self, requests_cookie_format=False):
class FakeCookies:
async def get_all(self, requests_cookie_format=False):
assert requests_cookie_format is True
return [
FakeCookie("cf_clearance", "abc", "example.com", "/", 0),
]
def get_user_agent(self):
class FakeDriver:
cookies = FakeCookies()
class FakePage:
async def evaluate(self, _expr):
return "TestUA/1.0"
internal_bypasser.clear_cf_cookies()
internal_bypasser._extract_cookies_from_cdp(FakeSb(), "https://example.com")
asyncio.run(
internal_bypasser._extract_cookies_from_cdp(
FakeDriver(),
FakePage(),
"https://example.com",
)
)
stored = internal_bypasser._cf_cookies.get("example.com", {})
assert stored["cf_clearance"]["expiry"] is None
+140
View File
@@ -0,0 +1,140 @@
"""Tests for notifications settings registration and validation."""
import shelfmark.config.notifications_settings as notifications_settings_module
from shelfmark.core import settings_registry
def _field_map(tab_name: str):
tab = settings_registry.get_settings_tab(tab_name)
assert tab is not None
return {field.key: field for field in tab.fields if hasattr(field, "key")}
def test_notifications_tab_registers_expected_fields():
fields = _field_map("notifications")
expected = {
"notifications_heading",
"ADMIN_NOTIFICATION_ROUTES",
"test_admin_notification",
"USER_NOTIFICATION_ROUTES",
}
assert expected.issubset(fields.keys())
assert fields["USER_NOTIFICATION_ROUTES"].user_overridable is True
assert fields["USER_NOTIFICATION_ROUTES"].hidden_in_ui is True
def test_on_save_notifications_rejects_invalid_urls(monkeypatch):
monkeypatch.setattr(
"shelfmark.config.notifications_settings.load_config_file",
lambda _tab: {},
)
result = notifications_settings_module._on_save_notifications(
{
"ADMIN_NOTIFICATION_ROUTES": [
{"event": "all", "url": "not-a-valid-url"},
],
}
)
assert result["error"] is True
assert "invalid global notification URL" in result["message"]
def test_on_save_notifications_normalizes_routes(monkeypatch):
monkeypatch.setattr(
"shelfmark.config.notifications_settings.load_config_file",
lambda _tab: {},
)
values = {
"ADMIN_NOTIFICATION_ROUTES": [
{"event": "all", "url": " ntfys://ntfy.sh/shelfmark "},
{"event": "request_created", "url": ""},
{"event": "request_created", "url": "ntfys://ntfy.sh/requests"},
{"event": "request_created", "url": "ntfys://ntfy.sh/requests"},
],
}
result = notifications_settings_module._on_save_notifications(values)
assert result["error"] is False
assert result["values"]["ADMIN_NOTIFICATION_ROUTES"] == [
{"event": ["all"], "url": "ntfys://ntfy.sh/shelfmark"},
{"event": ["request_created"], "url": ""},
{"event": ["request_created"], "url": "ntfys://ntfy.sh/requests"},
]
def test_on_save_notifications_normalizes_multiselect_event_rows(monkeypatch):
monkeypatch.setattr(
"shelfmark.config.notifications_settings.load_config_file",
lambda _tab: {},
)
values = {
"ADMIN_NOTIFICATION_ROUTES": [
{"event": ["download_complete", "request_created"], "url": "ntfys://ntfy.sh/mixed"},
{"event": ["request_created", "download_complete"], "url": "ntfys://ntfy.sh/mixed"},
{"event": ["all", "download_failed"], "url": "ntfys://ntfy.sh/all"},
],
}
result = notifications_settings_module._on_save_notifications(values)
assert result["error"] is False
assert result["values"]["ADMIN_NOTIFICATION_ROUTES"] == [
{"event": ["request_created", "download_complete"], "url": "ntfys://ntfy.sh/mixed"},
{"event": ["all"], "url": "ntfys://ntfy.sh/all"},
]
def test_on_save_notifications_allows_empty_routes(monkeypatch):
monkeypatch.setattr(
"shelfmark.config.notifications_settings.load_config_file",
lambda _tab: {},
)
result = notifications_settings_module._on_save_notifications(
{
"ADMIN_NOTIFICATION_ROUTES": [{"event": "all", "url": ""}],
}
)
assert result["error"] is False
assert result["values"]["ADMIN_NOTIFICATION_ROUTES"] == [{"event": ["all"], "url": ""}]
def test_test_admin_notification_action_uses_current_unsaved_values(monkeypatch):
monkeypatch.setattr(
"shelfmark.config.notifications_settings.load_config_file",
lambda _tab: {"ADMIN_NOTIFICATION_ROUTES": []},
)
captured: dict[str, object] = {}
def _fake_send_test_notification(urls):
captured["urls"] = urls
return {"success": True, "message": "ok"}
monkeypatch.setattr(
"shelfmark.config.notifications_settings.send_test_notification",
_fake_send_test_notification,
)
result = notifications_settings_module._test_admin_notification_action(
{
"ADMIN_NOTIFICATION_ROUTES": [
{"event": "all", "url": " ntfys://ntfy.sh/shelfmark "},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
],
}
)
assert result["success"] is True
assert captured["urls"] == [
"ntfys://ntfy.sh/shelfmark",
"ntfys://ntfy.sh/errors",
]
+25
View File
@@ -348,3 +348,28 @@ class TestSecurityOnSave:
result = _on_save_security({"AUTH_METHOD": "oidc"})
assert result["error"] is False
def test_on_save_normalizes_oidc_discovery_url(self, tmp_path, monkeypatch):
from shelfmark.config.security import _on_save_security
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
values = {"OIDC_DISCOVERY_URL": " 'auth.example.com/.well-known/openid-configuration/' "}
result = _on_save_security(values)
assert result["error"] is False
assert (
result["values"]["OIDC_DISCOVERY_URL"]
== "https://auth.example.com/.well-known/openid-configuration"
)
def test_on_save_normalizes_proxy_logout_url(self, tmp_path, monkeypatch):
from shelfmark.config.security import _on_save_security
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
values = {"PROXY_AUTH_LOGOUT_URL": "auth.example.com/logout"}
result = _on_save_security(values)
assert result["error"] is False
assert result["values"]["PROXY_AUTH_LOGOUT_URL"] == "https://auth.example.com/logout"
+4 -1
View File
@@ -197,7 +197,10 @@ def test_request_policy_rules_source_options_are_dynamic(monkeypatch):
assert {"value": "*", "label": "Any Type (*)", "childOf": "direct_download"} not in content_type_options
mode_options = columns[2]["options"]
assert mode_options[0] == {"value": "download", "label": "Download", "description": "Allow direct downloads."}
# This test verifies dynamic source/content-type option wiring; keep mode-copy checks non-brittle.
assert mode_options[0]["value"] == "download"
assert mode_options[0]["label"] == "Download"
assert isinstance(mode_options[0].get("description"), str) and mode_options[0]["description"].strip()
assert {opt["value"] for opt in mode_options} == {"download", "request_release", "blocked"}
@@ -10,6 +10,7 @@ from unittest.mock import patch
import pytest
from shelfmark.core.models import DownloadTask, QueueStatus
from shelfmark.core.notifications import NotificationEvent
@pytest.fixture(scope="module")
@@ -154,3 +155,90 @@ class TestTerminalSnapshotCapture:
assert snapshot["download"]["status_message"] == "Complete"
finally:
main_module.backend.book_queue.cancel_download(task_id)
def test_complete_transition_triggers_download_complete_notification(self, main_module):
user = _create_user(main_module, prefix="snap-notify-complete")
task_id = f"notify-complete-{uuid.uuid4().hex[:8]}"
task = DownloadTask(
task_id=task_id,
source="direct_download",
title="Notify Complete Snapshot",
author="Notify Author",
user_id=user["id"],
username=user["username"],
)
assert main_module.backend.book_queue.add(task) is True
try:
with patch.object(main_module, "notify_admin") as mock_notify:
with patch.object(main_module, "notify_user") as mock_notify_user:
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
mock_notify.assert_called_once()
event, context = mock_notify.call_args.args
assert event == NotificationEvent.DOWNLOAD_COMPLETE
assert context.title == "Notify Complete Snapshot"
assert context.author == "Notify Author"
assert context.username == user["username"]
mock_notify_user.assert_called_once()
user_id, user_event, user_context = mock_notify_user.call_args.args
assert user_id == user["id"]
assert user_event == NotificationEvent.DOWNLOAD_COMPLETE
assert user_context.title == "Notify Complete Snapshot"
finally:
main_module.backend.book_queue.cancel_download(task_id)
def test_error_transition_triggers_download_failed_notification(self, main_module):
user = _create_user(main_module, prefix="snap-notify-error")
task_id = f"notify-error-{uuid.uuid4().hex[:8]}"
task = DownloadTask(
task_id=task_id,
source="direct_download",
title="Notify Error Snapshot",
author="Notify Error Author",
user_id=user["id"],
username=user["username"],
)
assert main_module.backend.book_queue.add(task) is True
try:
main_module.backend.book_queue.update_status_message(task_id, "Resolver timed out")
with patch.object(main_module, "notify_admin") as mock_notify:
with patch.object(main_module, "notify_user") as mock_notify_user:
main_module.backend.book_queue.update_status(task_id, QueueStatus.ERROR)
mock_notify.assert_called_once()
event, context = mock_notify.call_args.args
assert event == NotificationEvent.DOWNLOAD_FAILED
assert context.title == "Notify Error Snapshot"
assert context.error_message == "Resolver timed out"
mock_notify_user.assert_called_once()
user_id, user_event, user_context = mock_notify_user.call_args.args
assert user_id == user["id"]
assert user_event == NotificationEvent.DOWNLOAD_FAILED
assert user_context.error_message == "Resolver timed out"
finally:
main_module.backend.book_queue.cancel_download(task_id)
def test_cancelled_transition_does_not_trigger_notification(self, main_module):
user = _create_user(main_module, prefix="snap-notify-cancel")
task_id = f"notify-cancel-{uuid.uuid4().hex[:8]}"
task = DownloadTask(
task_id=task_id,
source="direct_download",
title="Notify Cancel Snapshot",
author="Notify Cancel Author",
user_id=user["id"],
username=user["username"],
)
assert main_module.backend.book_queue.add(task) is True
try:
with patch.object(main_module, "notify_admin") as mock_notify:
with patch.object(main_module, "notify_user") as mock_notify_user:
main_module.backend.book_queue.update_status(task_id, QueueStatus.CANCELLED)
mock_notify.assert_not_called()
mock_notify_user.assert_not_called()
finally:
main_module.backend.book_queue.cancel_download(task_id)
+216
View File
@@ -491,6 +491,42 @@ class TestAdminUserUpdateEndpoint:
settings = user_db.get_user_settings(user["id"])
assert settings["DESTINATION_AUDIOBOOK"] == "/audiobooks/alice"
def test_update_user_settings_accepts_notification_overrides(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={
"settings": {
"USER_NOTIFICATION_ROUTES": [
{"event": "all", "url": " ntfys://ntfy.sh/alice "},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
],
}
},
)
assert resp.status_code == 200
settings = user_db.get_user_settings(user["id"])
assert settings["USER_NOTIFICATION_ROUTES"] == [
{"event": ["all"], "url": "ntfys://ntfy.sh/alice"},
{"event": ["download_failed"], "url": "ntfys://ntfy.sh/errors"},
]
def test_update_user_settings_rejects_invalid_notification_url(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"settings": {"USER_NOTIFICATION_ROUTES": [{"event": "all", "url": "not-a-valid-url"}]}},
)
assert resp.status_code == 400
assert resp.json["error"] == "Invalid settings payload"
assert any(
"Invalid value for USER_NOTIFICATION_ROUTES" in msg
for msg in resp.json["details"]
)
def test_update_user_settings_accepts_valid_request_policy_rule(self, admin_client, user_db):
user = user_db.create_user(username="alice")
@@ -1115,6 +1151,186 @@ class TestAdminDeliveryPreferences:
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# GET /api/admin/users/<id>/notification-preferences
# ---------------------------------------------------------------------------
class TestAdminNotificationPreferences:
"""Tests for GET /api/admin/users/<id>/notification-preferences."""
@pytest.fixture(autouse=True)
def setup_config(self, tmp_path, monkeypatch):
import json
from pathlib import Path
config_dir = str(tmp_path)
monkeypatch.setenv("CONFIG_DIR", config_dir)
monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", Path(config_dir))
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
notifications_config = {
"ADMIN_NOTIFICATION_ROUTES": [
{"event": "all", "url": "ntfys://ntfy.sh/admin"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/admin-errors"},
],
"USER_NOTIFICATION_ROUTES": [
{"event": "all", "url": "ntfys://ntfy.sh/default-user"},
],
}
(plugins_dir / "notifications.json").write_text(json.dumps(notifications_config))
from shelfmark.core.config import config as app_config
app_config.refresh()
def test_returns_curated_fields_and_effective_values(self, admin_client, user_db):
user = user_db.create_user(username="alice")
user_db.set_user_settings(
user["id"],
{
"USER_NOTIFICATION_ROUTES": [
{"event": "all", "url": "ntfys://ntfy.sh/alice"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/alice-errors"},
],
},
)
resp = admin_client.get(f"/api/admin/users/{user['id']}/notification-preferences")
assert resp.status_code == 200
data = resp.json
assert data["tab"] == "notifications"
assert data["keys"] == [
"USER_NOTIFICATION_ROUTES",
]
field_keys = [field["key"] for field in data["fields"]]
assert set(field_keys) == set(data["keys"])
assert data["userOverrides"]["USER_NOTIFICATION_ROUTES"] == [
{"event": "all", "url": "ntfys://ntfy.sh/alice"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/alice-errors"},
]
assert data["effective"]["USER_NOTIFICATION_ROUTES"]["source"] == "user_override"
def test_returns_404_for_unknown_user(self, admin_client):
resp = admin_client.get("/api/admin/users/9999/notification-preferences")
assert resp.status_code == 404
def test_requires_admin(self, regular_client, user_db):
user = user_db.create_user(username="alice")
resp = regular_client.get(f"/api/admin/users/{user['id']}/notification-preferences")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# POST /api/admin/users/<id>/notification-preferences/test
# ---------------------------------------------------------------------------
class TestAdminNotificationPreferencesTestAction:
"""Tests for POST /api/admin/users/<id>/notification-preferences/test."""
@pytest.fixture(autouse=True)
def setup_config(self, tmp_path, monkeypatch):
import json
from pathlib import Path
config_dir = str(tmp_path)
monkeypatch.setenv("CONFIG_DIR", config_dir)
monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", Path(config_dir))
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
notifications_config = {
"ADMIN_NOTIFICATION_ROUTES": [
{"event": "all", "url": "ntfys://ntfy.sh/admin"},
],
"USER_NOTIFICATION_ROUTES": [
{"event": "all", "url": "ntfys://ntfy.sh/default-user"},
],
}
(plugins_dir / "notifications.json").write_text(json.dumps(notifications_config))
from shelfmark.core.config import config as app_config
app_config.refresh()
def test_requires_admin(self, regular_client, user_db):
user = user_db.create_user(username="alice")
resp = regular_client.post(
f"/api/admin/users/{user['id']}/notification-preferences/test",
json={"USER_NOTIFICATION_ROUTES": [{"event": "all", "url": "ntfys://ntfy.sh/alice"}]},
)
assert resp.status_code == 403
def test_returns_404_for_unknown_user(self, admin_client):
resp = admin_client.post("/api/admin/users/9999/notification-preferences/test", json={})
assert resp.status_code == 404
def test_uses_payload_routes_when_provided(self, admin_client, user_db):
user = user_db.create_user(username="alice")
with patch(
"shelfmark.config.notifications_settings.send_test_notification",
return_value={"success": True, "message": "ok"},
) as mock_send:
resp = admin_client.post(
f"/api/admin/users/{user['id']}/notification-preferences/test",
json={
"USER_NOTIFICATION_ROUTES": [
{"event": "all", "url": " ntfys://ntfy.sh/alice "},
{"event": "download_failed", "url": "ntfys://ntfy.sh/alice-errors"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/alice-errors"},
]
},
)
assert resp.status_code == 200
assert resp.json["success"] is True
mock_send.assert_called_once_with(
["ntfys://ntfy.sh/alice", "ntfys://ntfy.sh/alice-errors"]
)
def test_uses_effective_routes_when_payload_missing(self, admin_client, user_db):
user = user_db.create_user(username="alice")
with patch(
"shelfmark.config.notifications_settings.send_test_notification",
return_value={"success": True, "message": "ok"},
) as mock_send:
resp = admin_client.post(
f"/api/admin/users/{user['id']}/notification-preferences/test",
)
assert resp.status_code == 200
assert resp.json["success"] is True
mock_send.assert_called_once_with(["ntfys://ntfy.sh/default-user"])
def test_rejects_invalid_urls(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.post(
f"/api/admin/users/{user['id']}/notification-preferences/test",
json={"USER_NOTIFICATION_ROUTES": [{"event": "all", "url": "not-a-valid-url"}]},
)
assert resp.status_code == 400
assert "invalid personal notification URL" in resp.json["message"]
def test_requires_at_least_one_url(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.post(
f"/api/admin/users/{user['id']}/notification-preferences/test",
json={"USER_NOTIFICATION_ROUTES": [{"event": "all", "url": ""}]},
)
assert resp.status_code == 400
assert "Add at least one personal notification URL route first." in resp.json["message"]
# ---------------------------------------------------------------------------
# GET /api/admin/settings/overrides-summary
# ---------------------------------------------------------------------------
+338
View File
@@ -0,0 +1,338 @@
"""Tests for core notification rendering and dispatch helpers."""
from shelfmark.core import notifications as notifications_module
class _FakeExecutor:
def __init__(self):
self.calls = []
def submit(self, fn, *args, **kwargs):
self.calls.append((fn, args, kwargs))
return object()
class _FakeNotifyType:
INFO = "INFO"
SUCCESS = "SUCCESS"
WARNING = "WARNING"
FAILURE = "FAILURE"
class _FakeAppriseClient:
def __init__(self):
self.add_calls = []
self.notify_calls = []
def add(self, url):
self.add_calls.append(url)
return True
def notify(self, **kwargs):
self.notify_calls.append(kwargs)
return True
class _FakeAppriseModule:
NotifyType = _FakeNotifyType
asset_kwargs: dict[str, str] | None = None
def __init__(self):
self.client = _FakeAppriseClient()
self.apprise_kwargs = {}
class AppriseAsset:
def __init__(self, **kwargs):
self.kwargs = kwargs
def Apprise(self, *args, **kwargs):
self.apprise_kwargs = kwargs
asset = kwargs.get("asset")
self.asset_kwargs = getattr(asset, "kwargs", None)
return self.client
def test_render_message_includes_admin_note_for_rejection():
context = notifications_module.NotificationContext(
event=notifications_module.NotificationEvent.REQUEST_REJECTED,
title="Example Book",
author="Example Author",
admin_note="Missing metadata",
)
title, body = notifications_module._render_message(context)
assert title == "Request Rejected"
assert "Missing metadata" in body
def test_render_message_includes_error_line_for_download_failure():
context = notifications_module.NotificationContext(
event=notifications_module.NotificationEvent.DOWNLOAD_FAILED,
title="Example Book",
author="Example Author",
error_message="Connection timeout",
)
title, body = notifications_module._render_message(context)
assert title == "Download Failed"
assert "Connection timeout" in body
def test_render_message_uses_request_approved_copy():
context = notifications_module.NotificationContext(
event=notifications_module.NotificationEvent.REQUEST_FULFILLED,
title="Example Book",
author="Example Author",
)
title, body = notifications_module._render_message(context)
assert title == "Request Approved"
assert "was approved." in body
def test_notify_admin_submits_non_blocking_when_route_matches_event(monkeypatch):
fake_executor = _FakeExecutor()
monkeypatch.setattr(notifications_module, "_executor", fake_executor)
monkeypatch.setattr(
notifications_module,
"_resolve_admin_routes",
lambda: [{"event": "request_created", "url": "discord://Webhook/Token"}],
)
context = notifications_module.NotificationContext(
event=notifications_module.NotificationEvent.REQUEST_CREATED,
title="Example Book",
author="Example Author",
username="reader",
)
notifications_module.notify_admin(
notifications_module.NotificationEvent.REQUEST_CREATED,
context,
)
assert len(fake_executor.calls) == 1
def test_notify_admin_skips_when_no_route_matches_event(monkeypatch):
fake_executor = _FakeExecutor()
monkeypatch.setattr(notifications_module, "_executor", fake_executor)
monkeypatch.setattr(
notifications_module,
"_resolve_admin_routes",
lambda: [{"event": "download_failed", "url": "discord://Webhook/Token"}],
)
context = notifications_module.NotificationContext(
event=notifications_module.NotificationEvent.REQUEST_CREATED,
title="Example Book",
author="Example Author",
)
notifications_module.notify_admin(
notifications_module.NotificationEvent.REQUEST_CREATED,
context,
)
assert fake_executor.calls == []
def test_send_admin_event_passes_expected_title_body_and_notify_type(monkeypatch):
fake_apprise = _FakeAppriseModule()
monkeypatch.setattr(notifications_module, "apprise", fake_apprise)
context = notifications_module.NotificationContext(
event=notifications_module.NotificationEvent.REQUEST_REJECTED,
title="Example Book",
author="Example Author",
admin_note="Rule blocked this source",
)
result = notifications_module._send_admin_event(
notifications_module.NotificationEvent.REQUEST_REJECTED,
context,
["discord://Webhook/Token"],
)
assert result["success"] is True
assert fake_apprise.client.notify_calls
notify_kwargs = fake_apprise.client.notify_calls[0]
assert notify_kwargs["title"] == "Request Rejected"
assert "Rule blocked this source" in notify_kwargs["body"]
assert notify_kwargs["notify_type"] == _FakeNotifyType.WARNING
def test_dispatch_to_apprise_uses_shelfmark_asset_defaults(monkeypatch):
fake_apprise = _FakeAppriseModule()
monkeypatch.setattr(notifications_module, "apprise", fake_apprise)
result = notifications_module._dispatch_to_apprise(
["ntfys://ntfy.sh/shelfmark"],
title="Test",
body="Body",
notify_type=_FakeNotifyType.INFO,
)
assert result["success"] is True
assert fake_apprise.asset_kwargs is not None
assert fake_apprise.asset_kwargs["app_id"] == "Shelfmark"
assert "logo.png" in fake_apprise.asset_kwargs["image_url_logo"]
def test_resolve_admin_routes_returns_empty_when_no_routes(monkeypatch):
def _fake_get(key, default=None):
if key == "ADMIN_NOTIFICATION_ROUTES":
return []
return default
monkeypatch.setattr(notifications_module.app_config, "get", _fake_get)
routes = notifications_module._resolve_admin_routes()
assert routes == []
def test_resolve_user_routes_uses_user_overrides(monkeypatch):
def _fake_get(key, default=None, user_id=None):
if user_id != 42:
return default
values = {
"USER_NOTIFICATION_ROUTES": [
{"event": "all", "url": " ntfys://ntfy.sh/alice "},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
],
}
return values.get(key, default)
monkeypatch.setattr(notifications_module.app_config, "get", _fake_get)
routes = notifications_module._resolve_user_routes(42)
assert routes == [
{"event": "all", "url": "ntfys://ntfy.sh/alice"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
]
def test_notify_user_submits_non_blocking_when_route_matches_event(monkeypatch):
fake_executor = _FakeExecutor()
monkeypatch.setattr(notifications_module, "_executor", fake_executor)
monkeypatch.setattr(
notifications_module,
"_resolve_user_routes",
lambda _user_id: [{"event": "download_failed", "url": "discord://Webhook/Token"}],
)
context = notifications_module.NotificationContext(
event=notifications_module.NotificationEvent.DOWNLOAD_FAILED,
title="Example Book",
author="Example Author",
username="reader",
)
notifications_module.notify_user(
7,
notifications_module.NotificationEvent.DOWNLOAD_FAILED,
context,
)
assert len(fake_executor.calls) == 1
def test_notify_user_skips_when_user_id_is_invalid(monkeypatch):
fake_executor = _FakeExecutor()
monkeypatch.setattr(notifications_module, "_executor", fake_executor)
context = notifications_module.NotificationContext(
event=notifications_module.NotificationEvent.DOWNLOAD_COMPLETE,
title="Example Book",
author="Example Author",
)
notifications_module.notify_user(
None,
notifications_module.NotificationEvent.DOWNLOAD_COMPLETE,
context,
)
assert fake_executor.calls == []
def test_resolve_route_urls_for_event_includes_all_and_specific_rows():
routes = [
{"event": "all", "url": "ntfys://ntfy.sh/all"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
{"event": "request_created", "url": "ntfys://ntfy.sh/requests"},
]
urls = notifications_module._resolve_route_urls_for_event(
routes,
notifications_module.NotificationEvent.DOWNLOAD_FAILED,
)
assert urls == [
"ntfys://ntfy.sh/all",
"ntfys://ntfy.sh/errors",
]
def test_resolve_route_urls_for_event_deduplicates_matching_urls():
routes = [
{"event": "all", "url": "ntfys://ntfy.sh/shared"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/shared"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
]
urls = notifications_module._resolve_route_urls_for_event(
routes,
notifications_module.NotificationEvent.DOWNLOAD_FAILED,
)
assert urls == [
"ntfys://ntfy.sh/shared",
"ntfys://ntfy.sh/errors",
]
def test_resolve_admin_routes_expands_multiselect_event_rows(monkeypatch):
def _fake_get(key, default=None):
if key == "ADMIN_NOTIFICATION_ROUTES":
return [
{"event": ["request_created", "download_failed"], "url": "ntfys://ntfy.sh/multi"},
{"event": ["all", "download_complete"], "url": "ntfys://ntfy.sh/all"},
]
return default
monkeypatch.setattr(notifications_module.app_config, "get", _fake_get)
routes = notifications_module._resolve_admin_routes()
assert routes == [
{"event": "request_created", "url": "ntfys://ntfy.sh/multi"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/multi"},
{"event": "all", "url": "ntfys://ntfy.sh/all"},
]
def test_resolve_user_routes_expands_multiselect_event_rows(monkeypatch):
def _fake_get(key, default=None, user_id=None):
if key != "USER_NOTIFICATION_ROUTES" or user_id != 7:
return default
return [
{"event": ["download_complete", "request_fulfilled"], "url": "ntfys://ntfy.sh/user-main"},
{"event": ["all", "download_failed"], "url": "ntfys://ntfy.sh/user-all"},
]
monkeypatch.setattr(notifications_module.app_config, "get", _fake_get)
routes = notifications_module._resolve_user_routes(7)
assert routes == [
{"event": "download_complete", "url": "ntfys://ntfy.sh/user-main"},
{"event": "request_fulfilled", "url": "ntfys://ntfy.sh/user-main"},
{"event": "all", "url": "ntfys://ntfy.sh/user-all"},
]
@@ -0,0 +1,125 @@
"""API-level tests for notifications settings and action routes."""
from __future__ import annotations
import importlib
import uuid
from unittest.mock import patch
import pytest
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def client(main_module):
return main_module.app.test_client()
def _create_user(main_module, *, prefix: str, role: str) -> dict:
username = f"{prefix}-{uuid.uuid4().hex[:8]}"
return main_module.user_db.create_user(username=username, role=role)
def _set_session(client, *, user_id: str, db_user_id: int, is_admin: bool) -> None:
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["db_user_id"] = db_user_id
sess["is_admin"] = is_admin
class TestNotificationsSettingsApi:
def test_notifications_action_requires_admin(self, main_module, client):
user = _create_user(main_module, prefix="reader", role="user")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
resp = client.post(
"/api/settings/notifications/action/test_admin_notification",
json={"ADMIN_NOTIFICATION_ROUTES": [{"event": "all", "url": "ntfys://ntfy.sh/demo"}]},
)
assert resp.status_code == 403
assert resp.json["error"] == "Admin access required"
def test_notifications_action_returns_400_when_no_routes(self, main_module, client):
admin = _create_user(main_module, prefix="admin", role="admin")
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch("shelfmark.config.notifications_settings.load_config_file", return_value={}):
resp = client.post(
"/api/settings/notifications/action/test_admin_notification",
json={"ADMIN_NOTIFICATION_ROUTES": []},
)
assert resp.status_code == 400
assert resp.json["success"] is False
assert "Add at least one global notification URL route" in resp.json["message"]
def test_notifications_action_uses_unsaved_values(self, main_module, client):
admin = _create_user(main_module, prefix="admin", role="admin")
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
captured: dict[str, object] = {}
def _fake_send_test_notification(urls):
captured["urls"] = urls
return {"success": True, "message": "test sent"}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch("shelfmark.config.notifications_settings.load_config_file", return_value={}):
with patch(
"shelfmark.config.notifications_settings.send_test_notification",
side_effect=_fake_send_test_notification,
):
resp = client.post(
"/api/settings/notifications/action/test_admin_notification",
json={
"ADMIN_NOTIFICATION_ROUTES": [
{"event": "all", "url": " ntfys://ntfy.sh/shelfmark "},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
],
},
)
assert resp.status_code == 200
assert resp.json["success"] is True
assert captured["urls"] == ["ntfys://ntfy.sh/shelfmark", "ntfys://ntfy.sh/errors"]
def test_notifications_put_and_get_round_trip_normalizes_values(self, main_module, client):
admin = _create_user(main_module, prefix="admin", role="admin")
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
payload = {
"ADMIN_NOTIFICATION_ROUTES": [
{"event": "all", "url": " ntfys://ntfy.sh/shelfmark "},
{"event": "request_created", "url": ""},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
{"event": "download_failed", "url": "ntfys://ntfy.sh/errors"},
],
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
put_resp = client.put("/api/settings/notifications", json=payload)
get_resp = client.get("/api/settings/notifications")
assert put_resp.status_code == 200
assert put_resp.json["success"] is True
assert get_resp.status_code == 200
fields = {field["key"]: field for field in get_resp.json["fields"] if "key" in field}
assert fields["ADMIN_NOTIFICATION_ROUTES"]["value"] == [
{"event": ["all"], "url": "ntfys://ntfy.sh/shelfmark"},
{"event": ["request_created"], "url": ""},
{"event": ["download_failed"], "url": "ntfys://ntfy.sh/errors"},
]
+14
View File
@@ -5,6 +5,7 @@ import tempfile
from unittest.mock import Mock, patch
import pytest
from authlib.jose.errors import InvalidClaimError
from flask import Flask, redirect
from shelfmark.core.user_db import UserDB
@@ -190,6 +191,19 @@ class TestOIDCCallbackEndpoint:
assert resp.status_code == 400
assert "missing user claims" in resp.get_json()["error"]
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_returns_400_with_issuer_guidance_on_invalid_issuer_claim(
self, mock_get_client, client
):
fake_client = Mock()
fake_client.authorize_access_token.side_effect = InvalidClaimError("iss")
fake_client.load_server_metadata.return_value = {"issuer": "https://auth.example.com/application/o/shelfmark/"}
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 400
assert "issuer validation failed" in resp.get_json()["error"]
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_rejects_when_auto_provision_disabled(self, mock_get_client, client):
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
+182
View File
@@ -7,6 +7,7 @@ import uuid
from unittest.mock import ANY, patch
import pytest
from shelfmark.core.notifications import NotificationEvent
@pytest.fixture(scope="module")
@@ -253,6 +254,84 @@ class TestRequestRoutes:
assert emitted_payloads["new_request"]["title"] == "Eventful Book"
assert emitted_payloads["request_update"]["request_id"] == request_id
def test_create_request_triggers_admin_notification(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
policy = _policy(default_ebook="request_book")
payload = {
"book_data": {
"title": "Notify Create Book",
"author": "Notify Create Author",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-notify-create",
},
"context": {
"source": "direct_download",
"content_type": "ebook",
"request_level": "book",
},
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module, "_load_users_request_policy_settings", return_value=policy):
with patch("shelfmark.core.request_routes._load_users_request_policy_settings", return_value=policy):
with patch("shelfmark.core.request_routes.notify_admin") as mock_notify:
with patch("shelfmark.core.request_routes.notify_user") as mock_notify_user:
resp = client.post("/api/requests", json=payload)
assert resp.status_code == 201
mock_notify.assert_called_once()
event, context = mock_notify.call_args.args
assert event == NotificationEvent.REQUEST_CREATED
assert context.title == "Notify Create Book"
assert context.author == "Notify Create Author"
assert context.username == user["username"]
mock_notify_user.assert_called_once()
user_id, user_event, user_context = mock_notify_user.call_args.args
assert user_id == user["id"]
assert user_event == NotificationEvent.REQUEST_CREATED
assert user_context.title == "Notify Create Book"
def test_create_request_succeeds_when_notification_dispatch_raises(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
policy = _policy(default_ebook="request_book")
payload = {
"book_data": {
"title": "Resilient Notify Create Book",
"author": "Resilient Notify Create Author",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-notify-resilience",
},
"context": {
"source": "direct_download",
"content_type": "ebook",
"request_level": "book",
},
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module, "_load_users_request_policy_settings", return_value=policy):
with patch("shelfmark.core.request_routes._load_users_request_policy_settings", return_value=policy):
with patch(
"shelfmark.core.request_routes.notify_admin",
side_effect=RuntimeError("admin notification unavailable"),
) as mock_notify_admin:
with patch(
"shelfmark.core.request_routes.notify_user",
side_effect=RuntimeError("user notification unavailable"),
) as mock_notify_user:
resp = client.post("/api/requests", json=payload)
assert resp.status_code == 201
assert resp.json["status"] == "pending"
mock_notify_admin.assert_called_once()
mock_notify_user.assert_called_once()
def test_cancel_request_emits_to_user_and_admin_rooms(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
@@ -571,6 +650,55 @@ class TestRequestRoutes:
mock_emit.assert_any_call("request_update", ANY, to=f"user_{user['id']}")
mock_emit.assert_any_call("request_update", ANY, to="admins")
def test_admin_reject_triggers_admin_notification(self, main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
policy = _policy(default_ebook="request_book")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
create_payload = {
"book_data": {
"title": "Reject Notify Book",
"author": "Reject Notify Author",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-reject-notify",
},
"context": {
"source": "direct_download",
"content_type": "ebook",
"request_level": "book",
},
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module, "_load_users_request_policy_settings", return_value=policy):
with patch("shelfmark.core.request_routes._load_users_request_policy_settings", return_value=policy):
create_resp = client.post("/api/requests", json=create_payload)
request_id = create_resp.json["id"]
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
with patch("shelfmark.core.request_routes.notify_admin") as mock_notify:
with patch("shelfmark.core.request_routes.notify_user") as mock_notify_user:
reject_resp = client.post(
f"/api/admin/requests/{request_id}/reject",
json={"admin_note": "Needs better metadata"},
)
assert create_resp.status_code == 201
assert reject_resp.status_code == 200
mock_notify.assert_called_once()
event, context = mock_notify.call_args.args
assert event == NotificationEvent.REQUEST_REJECTED
assert context.title == "Reject Notify Book"
assert context.admin_note == "Needs better metadata"
assert context.username == user["username"]
mock_notify_user.assert_called_once()
user_id, user_event, user_context = mock_notify_user.call_args.args
assert user_id == user["id"]
assert user_event == NotificationEvent.REQUEST_REJECTED
assert user_context.admin_note == "Needs better metadata"
def test_admin_fulfil_queues_for_requesting_user(self, main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
@@ -674,6 +802,60 @@ class TestRequestRoutes:
mock_emit.assert_any_call("request_update", ANY, to=f"user_{user['id']}")
mock_emit.assert_any_call("request_update", ANY, to="admins")
def test_admin_fulfil_triggers_admin_notification(self, main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
policy = _policy(default_ebook="request_release")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
create_payload = {
"book_data": {
"title": "Fulfil Notify Book",
"author": "Fulfil Notify Author",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-fulfil-notify",
},
"context": {
"source": "prowlarr",
"content_type": "ebook",
"request_level": "release",
},
"release_data": {
"source": "prowlarr",
"source_id": "rel-fulfil-notify",
"title": "Fulfil Notify Book.epub",
},
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module, "_load_users_request_policy_settings", return_value=policy):
with patch("shelfmark.core.request_routes._load_users_request_policy_settings", return_value=policy):
create_resp = client.post("/api/requests", json=create_payload)
request_id = create_resp.json["id"]
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
with patch.object(main_module.backend, "queue_release", return_value=(True, None)):
with patch("shelfmark.core.request_routes.notify_admin") as mock_notify:
with patch("shelfmark.core.request_routes.notify_user") as mock_notify_user:
fulfil_resp = client.post(
f"/api/admin/requests/{request_id}/fulfil",
json={"admin_note": "Approved"},
)
assert create_resp.status_code == 201
assert fulfil_resp.status_code == 200
mock_notify.assert_called_once()
event, context = mock_notify.call_args.args
assert event == NotificationEvent.REQUEST_FULFILLED
assert context.title == "Fulfil Notify Book"
assert context.username == user["username"]
mock_notify_user.assert_called_once()
user_id, user_event, user_context = mock_notify_user.call_args.args
assert user_id == user["id"]
assert user_event == NotificationEvent.REQUEST_FULFILLED
assert user_context.title == "Fulfil Notify Book"
def test_admin_fulfil_book_level_request_requires_release_data(self, main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
+42
View File
@@ -391,6 +391,48 @@ def test_fulfil_request_queues_as_requesting_user(user_db):
assert isinstance(captured["release_data"], dict)
def test_fulfil_request_rejects_when_state_changes_after_queue_dispatch(user_db):
alice = user_db.create_user(username="alice")
admin = user_db.create_user(username="admin", role="admin")
created = create_request(
user_db,
user_id=alice["id"],
source_hint="prowlarr",
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=_book_data(),
release_data=_release_data(),
)
release_data = _release_data()
def fake_queue_release(_release_data_arg, _priority, user_id=None, username=None):
# Simulate another worker fulfilling the same request while this call is in-flight.
user_db.update_request(
created["id"],
status="fulfilled",
release_data=release_data,
delivery_state="queued",
delivery_updated_at="2026-01-01T00:00:00+00:00",
reviewed_by=admin["id"],
reviewed_at="2026-01-01T00:00:00+00:00",
)
return True, None
with pytest.raises(RequestServiceError) as exc_info:
fulfil_request(
user_db,
request_id=created["id"],
admin_user_id=admin["id"],
queue_release=fake_queue_release,
)
assert exc_info.value.status_code == 409
assert exc_info.value.code == "stale_transition"
assert user_db.get_request(created["id"])["status"] == "fulfilled"
def test_fulfil_book_level_request_stores_selected_release_data(user_db):
alice = user_db.create_user(username="alice")
admin = user_db.create_user(username="admin", role="admin")
@@ -0,0 +1,112 @@
"""Tests for self-service notification test endpoint."""
import os
import tempfile
from unittest.mock import patch
import pytest
from flask import Flask
from shelfmark.core.self_user_routes import register_self_user_routes
from shelfmark.core.user_db import UserDB
@pytest.fixture
def db_path():
with tempfile.TemporaryDirectory() as tmpdir:
yield os.path.join(tmpdir, "shelfmark.db")
@pytest.fixture
def user_db(db_path):
db = UserDB(db_path)
db.initialize()
return db
@pytest.fixture
def app(user_db):
test_app = Flask(__name__)
test_app.config["SECRET_KEY"] = "test-secret"
test_app.config["TESTING"] = True
register_self_user_routes(test_app, user_db)
return test_app
class TestSelfNotificationPreferencesTestAction:
@pytest.fixture(autouse=True)
def setup_config(self, tmp_path, monkeypatch):
import json
from pathlib import Path
config_dir = str(tmp_path)
monkeypatch.setenv("CONFIG_DIR", config_dir)
monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", Path(config_dir))
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
notifications_config = {
"USER_NOTIFICATION_ROUTES": [
{"event": "all", "url": "ntfys://ntfy.sh/default-user"},
],
}
(plugins_dir / "notifications.json").write_text(json.dumps(notifications_config))
from shelfmark.core.config import config as app_config
app_config.refresh()
def test_users_me_notification_test_uses_payload_routes(self, app, user_db):
user = user_db.create_user(username="alice")
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user["username"]
sess["db_user_id"] = user["id"]
sess["is_admin"] = False
with patch(
"shelfmark.config.notifications_settings.send_test_notification",
return_value={"success": True, "message": "ok"},
) as mock_send:
resp = client.post(
"/api/users/me/notification-preferences/test",
json={
"USER_NOTIFICATION_ROUTES": [
{"event": "all", "url": " ntfys://ntfy.sh/alice "},
{"event": "download_failed", "url": "ntfys://ntfy.sh/alice-errors"},
]
},
)
assert resp.status_code == 200
assert resp.json["success"] is True
mock_send.assert_called_once_with(
["ntfys://ntfy.sh/alice", "ntfys://ntfy.sh/alice-errors"]
)
def test_users_me_notification_test_requires_user_context(self, app):
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = "alice"
resp = client.post("/api/users/me/notification-preferences/test", json={})
assert resp.status_code == 403
assert resp.json["error"] == "Authenticated session is missing local user context"
def test_users_me_notification_test_requires_at_least_one_url(self, app, user_db):
user = user_db.create_user(username="alice")
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user["username"]
sess["db_user_id"] = user["id"]
sess["is_admin"] = False
resp = client.post(
"/api/users/me/notification-preferences/test",
json={"USER_NOTIFICATION_ROUTES": [{"event": "all", "url": ""}]},
)
assert resp.status_code == 400
assert "Add at least one personal notification URL route first." in resp.json["message"]
+24
View File
@@ -532,6 +532,30 @@ class TestDownloadRequests:
assert updated["status"] == "fulfilled"
assert updated["admin_note"] == "done"
def test_update_request_expected_current_status_enforces_compare_and_swap(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
)
first = user_db.update_request(
created["id"],
expected_current_status="pending",
status="fulfilled",
)
assert first["status"] == "fulfilled"
with pytest.raises(ValueError, match="Request state changed before update"):
user_db.update_request(
created["id"],
expected_current_status="pending",
status="fulfilled",
)
def test_update_request_rejects_terminal_status_mutation(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
+15 -13
View File
@@ -393,19 +393,21 @@ class TestRTorrentClientGetStatus:
)
mock_rpc = MagicMock()
mock_rpc.d.multicall.filtered.return_value = [
mock_rpc.d.multicall.filtered.side_effect = [
[
"abc123def456",
4,
1048576000,
1048576000,
0,
2048000,
"cwabd",
1,
]
[
"abc123def456",
4,
1048576000,
1048576000,
0,
2048000,
"cwabd",
1,
]
],
[["/downloads/test-torrent"]],
]
mock_rpc.d.get_base_path.return_value = "/downloads/test-torrent"
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
@@ -630,7 +632,7 @@ class TestRTorrentClientGetDownloadPath:
)
mock_rpc = MagicMock()
mock_rpc.d.get_base_path.return_value = "/downloads/test-file"
mock_rpc.d.multicall.filtered.return_value = [["/downloads/test-file"]]
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
@@ -663,7 +665,7 @@ class TestRTorrentClientGetDownloadPath:
)
mock_rpc = MagicMock()
mock_rpc.d.get_base_path.side_effect = Exception("Torrent not found")
mock_rpc.d.multicall.filtered.return_value = []
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
+12 -60
View File
@@ -11,7 +11,6 @@ from shelfmark.release_sources.prowlarr.source import (
ProwlarrSource,
_parse_size,
_extract_format,
_extract_language,
_detect_content_type_from_categories,
)
from shelfmark.release_sources.prowlarr.utils import get_protocol_display, sanitize_download_url
@@ -189,65 +188,6 @@ class TestSanitizeDownloadUrl:
url = "https://prowlarr:9696/5/download?apikey=12345"
assert sanitize_download_url(url) == url
class TestExtractLanguage:
"""Tests for the _extract_language function."""
def test_extract_language_english(self):
"""Test extracting English language."""
assert _extract_language("The Book [English]") == "en"
assert _extract_language("Book (eng)") == "en"
assert _extract_language("Book [EN]") == "en"
def test_extract_language_german(self):
"""Test extracting German language."""
assert _extract_language("Das Buch [German]") == "de"
assert _extract_language("Buch (Deutsch)") == "de"
assert _extract_language("Buch [DE]") == "de"
def test_extract_language_french(self):
"""Test extracting French language."""
assert _extract_language("Le Livre [French]") == "fr"
assert _extract_language("Livre (Français)") == "fr"
assert _extract_language("Livre [FR]") == "fr"
def test_extract_language_spanish(self):
"""Test extracting Spanish language."""
assert _extract_language("El Libro [Spanish]") == "es"
assert _extract_language("Libro (Español)") == "es"
assert _extract_language("Libro [ES]") == "es"
def test_extract_language_italian(self):
"""Test extracting Italian language."""
assert _extract_language("Il Libro [Italian]") == "it"
assert _extract_language("Libro (Italiano)") == "it"
def test_extract_language_russian(self):
"""Test extracting Russian language."""
assert _extract_language("Book [Russian]") == "ru"
assert _extract_language("Book [RU]") == "ru"
def test_extract_language_japanese(self):
"""Test extracting Japanese language."""
assert _extract_language("Book [Japanese]") == "ja"
assert _extract_language("Book [JA]") == "ja"
def test_extract_language_chinese(self):
"""Test extracting Chinese language."""
assert _extract_language("Book [Chinese]") == "zh"
assert _extract_language("Book [ZH]") == "zh"
def test_extract_language_none_when_not_found(self):
"""Test that None is returned when no language found."""
assert _extract_language("The Book by Author") is None
assert _extract_language("") is None
def test_extract_language_case_insensitive(self):
"""Test that language extraction is case insensitive."""
assert _extract_language("Book [GERMAN]") == "de"
assert _extract_language("Book [german]") == "de"
assert _extract_language("Book [German]") == "de"
class TestDetectContentType:
"""Tests for the _detect_content_type_from_categories function."""
@@ -278,6 +218,9 @@ class TestProwlarrLocalizedQueries:
self.calls.append((query, categories))
return []
def get_enriched_indexer_ids(self, restrict_to=None):
return []
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):
@@ -316,6 +259,9 @@ class TestProwlarrLocalizedQueries:
self.calls.append((query, categories))
return []
def get_enriched_indexer_ids(self, restrict_to=None):
return []
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):
@@ -354,6 +300,9 @@ class TestProwlarrLocalizedQueries:
self.queries.append(query)
return []
def get_enriched_indexer_ids(self, restrict_to=None):
return []
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):
@@ -395,6 +344,9 @@ class TestProwlarrLocalizedQueries:
self.queries.append(query)
return []
def get_enriched_indexer_ids(self, restrict_to=None):
return []
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):