mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 13:40:21 +01:00
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:
+6
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -15,3 +15,4 @@ rarfile
|
||||
qbittorrent-api
|
||||
transmission-rpc
|
||||
authlib>=1.6.6,<1.7
|
||||
apprise>=1.9.0
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"},
|
||||
]
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user