606 lines
21 KiB
Python
606 lines
21 KiB
Python
from typing import Any, Dict, List
|
|
from datetime import datetime, timedelta, timezone
|
|
import secrets
|
|
import os
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
|
|
|
|
from ..auth import require_admin
|
|
from ..config import settings as env_settings
|
|
from ..db import (
|
|
delete_setting,
|
|
get_all_users,
|
|
get_invite_profile,
|
|
list_invite_profiles,
|
|
create_invite_profile,
|
|
create_invite,
|
|
list_invites,
|
|
disable_invite,
|
|
delete_invite,
|
|
delete_user,
|
|
get_all_contacts,
|
|
get_request_cache_overview,
|
|
save_announcement,
|
|
get_settings_overrides,
|
|
get_user_by_username,
|
|
set_setting,
|
|
set_user_blocked,
|
|
set_user_password,
|
|
set_user_role,
|
|
run_integrity_check,
|
|
vacuum_db,
|
|
clear_requests_cache,
|
|
clear_history,
|
|
cleanup_history,
|
|
)
|
|
from ..runtime import get_runtime_settings
|
|
from ..clients.sonarr import SonarrClient
|
|
from ..clients.radarr import RadarrClient
|
|
from ..clients.jellyfin import JellyfinClient
|
|
from ..clients.jellyseerr import JellyseerrClient
|
|
from ..services.jellyfin_sync import sync_jellyfin_users
|
|
from ..services.jellyseerr_sync import sync_jellyseerr_users
|
|
import logging
|
|
from ..logging_config import configure_logging
|
|
from ..routers import requests as requests_router
|
|
from ..routers.branding import save_branding_image
|
|
from ..services.notifications import send_notification
|
|
|
|
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SENSITIVE_KEYS = {
|
|
"jellyseerr_api_key",
|
|
"jellyfin_api_key",
|
|
"sonarr_api_key",
|
|
"radarr_api_key",
|
|
"prowlarr_api_key",
|
|
"qbittorrent_password",
|
|
"smtp_password",
|
|
"hcaptcha_secret_key",
|
|
"recaptcha_secret_key",
|
|
"turnstile_secret_key",
|
|
"telegram_bot_token",
|
|
"matrix_password",
|
|
"matrix_access_token",
|
|
"pushover_token",
|
|
"pushover_user_key",
|
|
"pushbullet_token",
|
|
"gotify_token",
|
|
}
|
|
|
|
SETTING_KEYS: List[str] = [
|
|
"jellyseerr_base_url",
|
|
"jellyseerr_api_key",
|
|
"jellyfin_base_url",
|
|
"jellyfin_api_key",
|
|
"jellyfin_public_url",
|
|
"jellyfin_sync_to_arr",
|
|
"artwork_cache_mode",
|
|
"sonarr_base_url",
|
|
"sonarr_api_key",
|
|
"sonarr_quality_profile_id",
|
|
"sonarr_root_folder",
|
|
"radarr_base_url",
|
|
"radarr_api_key",
|
|
"radarr_quality_profile_id",
|
|
"radarr_root_folder",
|
|
"prowlarr_base_url",
|
|
"prowlarr_api_key",
|
|
"qbittorrent_base_url",
|
|
"qbittorrent_username",
|
|
"qbittorrent_password",
|
|
"log_level",
|
|
"log_file",
|
|
"requests_sync_ttl_minutes",
|
|
"requests_poll_interval_seconds",
|
|
"requests_delta_sync_interval_minutes",
|
|
"requests_full_sync_time",
|
|
"requests_cleanup_time",
|
|
"requests_cleanup_days",
|
|
"requests_data_source",
|
|
"invites_enabled",
|
|
"invites_require_captcha",
|
|
"invite_default_profile_id",
|
|
"signup_allow_referrals",
|
|
"referral_default_uses",
|
|
"password_min_length",
|
|
"password_require_upper",
|
|
"password_require_lower",
|
|
"password_require_number",
|
|
"password_require_symbol",
|
|
"password_reset_enabled",
|
|
"captcha_provider",
|
|
"hcaptcha_site_key",
|
|
"hcaptcha_secret_key",
|
|
"recaptcha_site_key",
|
|
"recaptcha_secret_key",
|
|
"turnstile_site_key",
|
|
"turnstile_secret_key",
|
|
"smtp_host",
|
|
"smtp_port",
|
|
"smtp_user",
|
|
"smtp_password",
|
|
"smtp_from",
|
|
"smtp_tls",
|
|
"smtp_starttls",
|
|
"notify_email_enabled",
|
|
"notify_discord_enabled",
|
|
"notify_telegram_enabled",
|
|
"notify_matrix_enabled",
|
|
"notify_pushover_enabled",
|
|
"notify_pushbullet_enabled",
|
|
"notify_gotify_enabled",
|
|
"notify_ntfy_enabled",
|
|
"telegram_bot_token",
|
|
"telegram_chat_id",
|
|
"matrix_homeserver",
|
|
"matrix_user",
|
|
"matrix_password",
|
|
"matrix_access_token",
|
|
"matrix_room_id",
|
|
"pushover_token",
|
|
"pushover_user_key",
|
|
"pushbullet_token",
|
|
"gotify_url",
|
|
"gotify_token",
|
|
"ntfy_url",
|
|
"ntfy_topic",
|
|
"expiry_default_days",
|
|
"expiry_default_action",
|
|
"expiry_warning_days",
|
|
"expiry_check_interval_minutes",
|
|
"jellyseerr_sync_users",
|
|
"jellyseerr_sync_interval_minutes",
|
|
]
|
|
|
|
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
|
|
if not isinstance(folders, list):
|
|
return []
|
|
results = []
|
|
for folder in folders:
|
|
if not isinstance(folder, dict):
|
|
continue
|
|
folder_id = folder.get("id")
|
|
path = folder.get("path")
|
|
if folder_id is None or path is None:
|
|
continue
|
|
results.append({"id": folder_id, "path": path, "label": path})
|
|
return results
|
|
|
|
|
|
def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]:
|
|
if not isinstance(profiles, list):
|
|
return []
|
|
results = []
|
|
for profile in profiles:
|
|
if not isinstance(profile, dict):
|
|
continue
|
|
profile_id = profile.get("id")
|
|
name = profile.get("name")
|
|
if profile_id is None or name is None:
|
|
continue
|
|
results.append({"id": profile_id, "name": name, "label": name})
|
|
return results
|
|
|
|
|
|
@router.get("/settings")
|
|
async def list_settings() -> Dict[str, Any]:
|
|
overrides = get_settings_overrides()
|
|
results = []
|
|
for key in SETTING_KEYS:
|
|
override_present = key in overrides
|
|
value = overrides.get(key) if override_present else getattr(env_settings, key)
|
|
is_set = value is not None and str(value).strip() != ""
|
|
sensitive = key in SENSITIVE_KEYS
|
|
results.append(
|
|
{
|
|
"key": key,
|
|
"value": None if sensitive else value,
|
|
"isSet": is_set,
|
|
"source": "db" if override_present else ("env" if is_set else "unset"),
|
|
"sensitive": sensitive,
|
|
}
|
|
)
|
|
return {"settings": results}
|
|
|
|
|
|
@router.put("/settings")
|
|
async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
updates = 0
|
|
touched_logging = False
|
|
for key, value in payload.items():
|
|
if key not in SETTING_KEYS:
|
|
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
|
|
if value is None:
|
|
continue
|
|
if isinstance(value, str) and value.strip() == "":
|
|
delete_setting(key)
|
|
updates += 1
|
|
continue
|
|
set_setting(key, str(value))
|
|
updates += 1
|
|
if key in {"log_level", "log_file"}:
|
|
touched_logging = True
|
|
if touched_logging:
|
|
runtime = get_runtime_settings()
|
|
configure_logging(runtime.log_level, runtime.log_file)
|
|
return {"status": "ok", "updated": updates}
|
|
|
|
|
|
@router.get("/sonarr/options")
|
|
async def sonarr_options() -> Dict[str, Any]:
|
|
runtime = get_runtime_settings()
|
|
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
|
if not client.configured():
|
|
raise HTTPException(status_code=400, detail="Sonarr not configured")
|
|
root_folders = await client.get_root_folders()
|
|
profiles = await client.get_quality_profiles()
|
|
return {
|
|
"rootFolders": _normalize_root_folders(root_folders),
|
|
"qualityProfiles": _normalize_quality_profiles(profiles),
|
|
}
|
|
|
|
|
|
@router.get("/radarr/options")
|
|
async def radarr_options() -> Dict[str, Any]:
|
|
runtime = get_runtime_settings()
|
|
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
|
if not client.configured():
|
|
raise HTTPException(status_code=400, detail="Radarr not configured")
|
|
root_folders = await client.get_root_folders()
|
|
profiles = await client.get_quality_profiles()
|
|
return {
|
|
"rootFolders": _normalize_root_folders(root_folders),
|
|
"qualityProfiles": _normalize_quality_profiles(profiles),
|
|
}
|
|
|
|
|
|
@router.get("/jellyfin/users")
|
|
async def jellyfin_users() -> Dict[str, Any]:
|
|
runtime = get_runtime_settings()
|
|
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
if not client.configured():
|
|
raise HTTPException(status_code=400, detail="Jellyfin not configured")
|
|
users = await client.get_users()
|
|
if not isinstance(users, list):
|
|
return {"users": []}
|
|
results = []
|
|
for user in users:
|
|
if not isinstance(user, dict):
|
|
continue
|
|
results.append(
|
|
{
|
|
"id": user.get("Id"),
|
|
"name": user.get("Name"),
|
|
"hasPassword": user.get("HasPassword"),
|
|
"lastLoginDate": user.get("LastLoginDate"),
|
|
}
|
|
)
|
|
return {"users": results}
|
|
|
|
|
|
@router.post("/jellyfin/users/sync")
|
|
async def jellyfin_users_sync() -> Dict[str, Any]:
|
|
imported = await sync_jellyfin_users()
|
|
return {"status": "ok", "imported": imported}
|
|
|
|
|
|
@router.post("/jellyseerr/users/sync")
|
|
async def jellyseerr_users_sync() -> Dict[str, Any]:
|
|
imported = await sync_jellyseerr_users()
|
|
return {"status": "ok", "imported": imported}
|
|
|
|
|
|
@router.post("/requests/sync")
|
|
async def requests_sync() -> Dict[str, Any]:
|
|
runtime = get_runtime_settings()
|
|
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
|
if not client.configured():
|
|
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
|
|
state = await requests_router.start_requests_sync(
|
|
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
|
|
)
|
|
logger.info("Admin triggered requests sync: status=%s", state.get("status"))
|
|
return {"status": "ok", "sync": state}
|
|
|
|
|
|
@router.post("/requests/sync/delta")
|
|
async def requests_sync_delta() -> Dict[str, Any]:
|
|
runtime = get_runtime_settings()
|
|
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
|
if not client.configured():
|
|
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
|
|
state = await requests_router.start_requests_delta_sync(
|
|
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
|
|
)
|
|
logger.info("Admin triggered delta requests sync: status=%s", state.get("status"))
|
|
return {"status": "ok", "sync": state}
|
|
|
|
|
|
@router.post("/requests/artwork/prefetch")
|
|
async def requests_artwork_prefetch() -> Dict[str, Any]:
|
|
runtime = get_runtime_settings()
|
|
state = await requests_router.start_artwork_prefetch(
|
|
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
|
|
)
|
|
logger.info("Admin triggered artwork prefetch: status=%s", state.get("status"))
|
|
return {"status": "ok", "prefetch": state}
|
|
|
|
|
|
@router.get("/requests/artwork/status")
|
|
async def requests_artwork_status() -> Dict[str, Any]:
|
|
return {"status": "ok", "prefetch": requests_router.get_artwork_prefetch_state()}
|
|
|
|
|
|
@router.get("/requests/sync/status")
|
|
async def requests_sync_status() -> Dict[str, Any]:
|
|
return {"status": "ok", "sync": requests_router.get_requests_sync_state()}
|
|
|
|
|
|
@router.get("/logs")
|
|
async def read_logs(lines: int = 200) -> Dict[str, Any]:
|
|
runtime = get_runtime_settings()
|
|
log_file = runtime.log_file
|
|
if not log_file:
|
|
raise HTTPException(status_code=400, detail="Log file not configured")
|
|
if not os.path.isabs(log_file):
|
|
log_file = os.path.join(os.getcwd(), log_file)
|
|
if not os.path.exists(log_file):
|
|
raise HTTPException(status_code=404, detail="Log file not found")
|
|
lines = max(1, min(lines, 1000))
|
|
from collections import deque
|
|
|
|
with open(log_file, "r", encoding="utf-8", errors="replace") as handle:
|
|
tail = deque(handle, maxlen=lines)
|
|
return {"lines": list(tail)}
|
|
|
|
|
|
@router.get("/requests/cache")
|
|
async def requests_cache(limit: int = 50) -> Dict[str, Any]:
|
|
return {"rows": get_request_cache_overview(limit)}
|
|
|
|
|
|
@router.post("/branding/logo")
|
|
async def upload_branding_logo(file: UploadFile = File(...)) -> Dict[str, Any]:
|
|
return await save_branding_image(file)
|
|
|
|
|
|
@router.post("/maintenance/repair")
|
|
async def repair_database() -> Dict[str, Any]:
|
|
result = run_integrity_check()
|
|
vacuum_db()
|
|
logger.info("Database repair executed: integrity_check=%s", result)
|
|
return {"status": "ok", "integrity": result}
|
|
|
|
|
|
@router.post("/maintenance/flush")
|
|
async def flush_database() -> Dict[str, Any]:
|
|
cleared = clear_requests_cache()
|
|
history = clear_history()
|
|
delete_setting("requests_sync_last_at")
|
|
logger.warning("Database flush executed: requests_cache=%s history=%s", cleared, history)
|
|
return {"status": "ok", "requestsCleared": cleared, "historyCleared": history}
|
|
|
|
|
|
@router.post("/maintenance/cleanup")
|
|
async def cleanup_database(days: int = 90) -> Dict[str, Any]:
|
|
result = cleanup_history(days)
|
|
logger.info("Database cleanup executed: days=%s result=%s", days, result)
|
|
return {"status": "ok", "days": days, "cleared": result}
|
|
|
|
|
|
@router.post("/maintenance/logs/clear")
|
|
async def clear_logs() -> Dict[str, Any]:
|
|
runtime = get_runtime_settings()
|
|
log_file = runtime.log_file
|
|
if not log_file:
|
|
raise HTTPException(status_code=400, detail="Log file not configured")
|
|
if not os.path.isabs(log_file):
|
|
log_file = os.path.join(os.getcwd(), log_file)
|
|
try:
|
|
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
|
with open(log_file, "w", encoding="utf-8"):
|
|
pass
|
|
except OSError as exc:
|
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
logger.info("Log file cleared")
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/users")
|
|
async def list_users() -> Dict[str, Any]:
|
|
users = get_all_users()
|
|
return {"users": users}
|
|
|
|
|
|
@router.post("/users/{username}/block")
|
|
async def block_user(username: str) -> Dict[str, Any]:
|
|
set_user_blocked(username, True)
|
|
return {"status": "ok", "username": username, "blocked": True}
|
|
|
|
|
|
@router.post("/users/{username}/unblock")
|
|
async def unblock_user(username: str) -> Dict[str, Any]:
|
|
set_user_blocked(username, False)
|
|
return {"status": "ok", "username": username, "blocked": False}
|
|
|
|
|
|
@router.post("/users/{username}/role")
|
|
async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
role = payload.get("role")
|
|
if role not in {"admin", "user"}:
|
|
raise HTTPException(status_code=400, detail="Invalid role")
|
|
set_user_role(username, role)
|
|
return {"status": "ok", "username": username, "role": role}
|
|
|
|
|
|
@router.post("/users/{username}/password")
|
|
async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
new_password = payload.get("password") if isinstance(payload, dict) else None
|
|
if not isinstance(new_password, str) or len(new_password.strip()) < 8:
|
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
|
|
user = get_user_by_username(username)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if user.get("auth_provider") != "local":
|
|
raise HTTPException(
|
|
status_code=400, detail="Password changes are only available for local users."
|
|
)
|
|
set_user_password(username, new_password.strip())
|
|
return {"status": "ok", "username": username}
|
|
|
|
|
|
@router.post("/users/bulk")
|
|
async def bulk_user_action(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
action = str(payload.get("action") or "").strip().lower()
|
|
usernames = payload.get("usernames")
|
|
if not isinstance(usernames, list) or not usernames:
|
|
raise HTTPException(status_code=400, detail="User list required")
|
|
if action not in {"block", "unblock", "delete", "role"}:
|
|
raise HTTPException(status_code=400, detail="Invalid action")
|
|
updated = 0
|
|
for username in usernames:
|
|
if not isinstance(username, str) or not username.strip():
|
|
continue
|
|
name = username.strip()
|
|
if action == "block":
|
|
set_user_blocked(name, True)
|
|
elif action == "unblock":
|
|
set_user_blocked(name, False)
|
|
elif action == "delete":
|
|
delete_user(name)
|
|
elif action == "role":
|
|
role = str(payload.get("role") or "").strip().lower()
|
|
if role not in {"admin", "user"}:
|
|
raise HTTPException(status_code=400, detail="Invalid role")
|
|
set_user_role(name, role)
|
|
updated += 1
|
|
return {"status": "ok", "updated": updated}
|
|
|
|
|
|
@router.get("/invite-profiles")
|
|
async def invite_profiles() -> Dict[str, Any]:
|
|
return {"profiles": list_invite_profiles()}
|
|
|
|
|
|
@router.post("/invite-profiles")
|
|
async def create_profile(
|
|
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
|
|
) -> Dict[str, Any]:
|
|
name = str(payload.get("name") or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Profile name required")
|
|
profile_id = create_invite_profile(
|
|
name=name,
|
|
description=str(payload.get("description") or "").strip() or None,
|
|
max_uses=payload.get("max_uses"),
|
|
expires_in_days=payload.get("expires_in_days"),
|
|
require_captcha=bool(payload.get("require_captcha")),
|
|
password_rules=payload.get("password_rules") if isinstance(payload.get("password_rules"), dict) else None,
|
|
allow_referrals=bool(payload.get("allow_referrals")),
|
|
referral_uses=payload.get("referral_uses"),
|
|
user_expiry_days=payload.get("user_expiry_days"),
|
|
user_expiry_action=str(payload.get("user_expiry_action") or "").strip() or None,
|
|
)
|
|
return {"status": "ok", "id": profile_id}
|
|
|
|
|
|
@router.get("/invites")
|
|
async def list_invites_endpoint(limit: int = 200) -> Dict[str, Any]:
|
|
return {"invites": list_invites(limit)}
|
|
|
|
|
|
@router.post("/invites")
|
|
async def create_invite_endpoint(
|
|
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
|
|
) -> Dict[str, Any]:
|
|
runtime = get_runtime_settings()
|
|
profile_id = payload.get("profile_id")
|
|
profile = None
|
|
if profile_id is not None:
|
|
try:
|
|
profile = get_invite_profile(int(profile_id))
|
|
except (TypeError, ValueError):
|
|
profile = None
|
|
expires_in_days = payload.get("expires_in_days") or (profile.get("expires_in_days") if profile else None)
|
|
expires_at = None
|
|
if expires_in_days:
|
|
try:
|
|
expires_at = (
|
|
datetime.now(timezone.utc) + timedelta(days=float(expires_in_days))
|
|
).isoformat()
|
|
except (TypeError, ValueError):
|
|
expires_at = None
|
|
require_captcha = bool(payload.get("require_captcha"))
|
|
if not require_captcha and profile:
|
|
require_captcha = bool(profile.get("require_captcha"))
|
|
if not require_captcha:
|
|
require_captcha = runtime.invites_require_captcha
|
|
password_rules = payload.get("password_rules")
|
|
if not isinstance(password_rules, dict):
|
|
password_rules = profile.get("password_rules") if profile else None
|
|
allow_referrals = bool(payload.get("allow_referrals"))
|
|
if not allow_referrals and profile:
|
|
allow_referrals = bool(profile.get("allow_referrals"))
|
|
user_expiry_days = payload.get("user_expiry_days") or (profile.get("user_expiry_days") if profile else None)
|
|
user_expiry_action = payload.get("user_expiry_action") or (profile.get("user_expiry_action") if profile else None)
|
|
code = secrets.token_urlsafe(8)
|
|
create_invite(
|
|
code=code,
|
|
created_by=user.get("username"),
|
|
profile_id=int(profile_id) if profile_id is not None else None,
|
|
expires_at=expires_at,
|
|
max_uses=payload.get("max_uses") or (profile.get("max_uses") if profile else None),
|
|
require_captcha=require_captcha,
|
|
password_rules=password_rules if isinstance(password_rules, dict) else None,
|
|
allow_referrals=allow_referrals,
|
|
referral_uses=payload.get("referral_uses") or (profile.get("referral_uses") if profile else None),
|
|
user_expiry_days=user_expiry_days,
|
|
user_expiry_action=str(user_expiry_action) if user_expiry_action else None,
|
|
is_referral=bool(payload.get("is_referral")),
|
|
)
|
|
return {"status": "ok", "code": code}
|
|
|
|
|
|
@router.post("/invites/{code}/disable")
|
|
async def disable_invite_endpoint(code: str) -> Dict[str, Any]:
|
|
disable_invite(code)
|
|
return {"status": "ok", "code": code, "disabled": True}
|
|
|
|
|
|
@router.delete("/invites/{code}")
|
|
async def delete_invite_endpoint(code: str) -> Dict[str, Any]:
|
|
delete_invite(code)
|
|
return {"status": "ok", "code": code, "deleted": True}
|
|
|
|
|
|
@router.post("/announcements")
|
|
async def send_announcement(
|
|
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
|
|
) -> Dict[str, Any]:
|
|
subject = str(payload.get("subject") or "").strip()
|
|
body = str(payload.get("body") or "").strip()
|
|
channels = payload.get("channels") if isinstance(payload.get("channels"), list) else []
|
|
if not subject or not body:
|
|
raise HTTPException(status_code=400, detail="Subject and message required")
|
|
results: Dict[str, Any] = {}
|
|
email_count = 0
|
|
email_failed = 0
|
|
if "email" in [str(c).lower() for c in channels]:
|
|
for contact in get_all_contacts():
|
|
email = contact.get("email")
|
|
if not email:
|
|
continue
|
|
outcome = await send_notification(subject, body, channels=["email"], email=email)
|
|
if outcome.get("email") == "sent":
|
|
email_count += 1
|
|
else:
|
|
email_failed += 1
|
|
results["email"] = {"sent": email_count, "failed": email_failed}
|
|
other_channels = [c for c in channels if str(c).lower() != "email"]
|
|
if other_channels:
|
|
results.update(await send_notification(subject, body, channels=other_channels))
|
|
save_announcement(user.get("username"), subject, body, ",".join(channels))
|
|
return {"status": "ok", "results": results}
|