- Task 1: Mark imported blocklist IP addresses
- Add BanOrigin type and _derive_origin() to ban.py model
- Populate origin field in ban_service list_bans() and bans_by_country()
- BanTable and MapPage companion table show origin badge column
- Tests: origin derivation in test_ban_service.py and test_dashboard.py
- Task 2: Add origin filter to dashboard and world map
- ban_service: _origin_sql_filter() helper; origin param on list_bans()
and bans_by_country()
- dashboard router: optional origin query param forwarded to service
- Frontend: BanOriginFilter type + BAN_ORIGIN_FILTER_LABELS in ban.ts
- fetchBans / fetchBansByCountry forward origin to API
- useBans / useMapData accept and pass origin; page resets on change
- BanTable accepts origin prop; DashboardPage adds segmented filter
- MapPage adds origin Select next to time-range picker
- Tests: origin filter assertions in test_ban_service and test_dashboard
661 lines
21 KiB
Python
661 lines
21 KiB
Python
"""Configuration inspection and editing service.
|
|
|
|
Provides methods to read and update fail2ban jail configuration and global
|
|
server settings via the Unix domain socket. Regex validation is performed
|
|
locally with Python's :mod:`re` module before any write is sent to the daemon
|
|
so that invalid patterns are rejected early.
|
|
|
|
Architecture note: this module is a pure service — it contains **no**
|
|
HTTP/FastAPI concerns. All results are returned as Pydantic models so
|
|
routers can serialise them directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import re
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import structlog
|
|
|
|
if TYPE_CHECKING:
|
|
import aiosqlite
|
|
|
|
from app.models.config import (
|
|
AddLogPathRequest,
|
|
GlobalConfigResponse,
|
|
GlobalConfigUpdate,
|
|
JailConfig,
|
|
JailConfigListResponse,
|
|
JailConfigResponse,
|
|
JailConfigUpdate,
|
|
LogPreviewLine,
|
|
LogPreviewRequest,
|
|
LogPreviewResponse,
|
|
MapColorThresholdsResponse,
|
|
MapColorThresholdsUpdate,
|
|
RegexTestRequest,
|
|
RegexTestResponse,
|
|
)
|
|
from app.services import setup_service
|
|
from app.utils.fail2ban_client import Fail2BanClient
|
|
|
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
|
|
|
_SOCKET_TIMEOUT: float = 10.0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Custom exceptions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class JailNotFoundError(Exception):
|
|
"""Raised when a requested jail name does not exist in fail2ban."""
|
|
|
|
def __init__(self, name: str) -> None:
|
|
"""Initialise with the jail name that was not found.
|
|
|
|
Args:
|
|
name: The jail name that could not be located.
|
|
"""
|
|
self.name: str = name
|
|
super().__init__(f"Jail not found: {name!r}")
|
|
|
|
|
|
class ConfigValidationError(Exception):
|
|
"""Raised when a configuration value fails validation before writing."""
|
|
|
|
|
|
class ConfigOperationError(Exception):
|
|
"""Raised when a configuration write command fails."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers (mirrored from jail_service for isolation)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ok(response: Any) -> Any:
|
|
"""Extract payload from a fail2ban ``(return_code, data)`` response.
|
|
|
|
Args:
|
|
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
|
|
|
Returns:
|
|
The payload ``data`` portion of the response.
|
|
|
|
Raises:
|
|
ValueError: If the return code indicates an error.
|
|
"""
|
|
try:
|
|
code, data = response
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc
|
|
if code != 0:
|
|
raise ValueError(f"fail2ban returned error code {code}: {data!r}")
|
|
return data
|
|
|
|
|
|
def _to_dict(pairs: Any) -> dict[str, Any]:
|
|
"""Convert a list of ``(key, value)`` pairs to a plain dict."""
|
|
if not isinstance(pairs, (list, tuple)):
|
|
return {}
|
|
result: dict[str, Any] = {}
|
|
for item in pairs:
|
|
try:
|
|
k, v = item
|
|
result[str(k)] = v
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return result
|
|
|
|
|
|
def _ensure_list(value: Any) -> list[str]:
|
|
"""Coerce a fail2ban ``get`` result to a list of strings."""
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, str):
|
|
return [value] if value.strip() else []
|
|
if isinstance(value, (list, tuple)):
|
|
return [str(v) for v in value if v is not None]
|
|
return [str(value)]
|
|
|
|
|
|
async def _safe_get(
|
|
client: Fail2BanClient,
|
|
command: list[Any],
|
|
default: Any = None,
|
|
) -> Any:
|
|
"""Send a command and return *default* if it fails."""
|
|
try:
|
|
return _ok(await client.send(command))
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def _is_not_found_error(exc: Exception) -> bool:
|
|
"""Return ``True`` if *exc* signals an unknown jail."""
|
|
msg = str(exc).lower()
|
|
return any(
|
|
phrase in msg
|
|
for phrase in ("unknown jail", "no jail", "does not exist", "not found")
|
|
)
|
|
|
|
|
|
def _validate_regex(pattern: str) -> str | None:
|
|
"""Try to compile *pattern* and return an error message if invalid.
|
|
|
|
Args:
|
|
pattern: A regex pattern string to validate.
|
|
|
|
Returns:
|
|
``None`` if valid, or an error message string if the pattern is broken.
|
|
"""
|
|
try:
|
|
re.compile(pattern)
|
|
return None
|
|
except re.error as exc:
|
|
return str(exc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API — read jail configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
|
|
"""Return the editable configuration for a single jail.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
name: Jail name.
|
|
|
|
Returns:
|
|
:class:`~app.models.config.JailConfigResponse`.
|
|
|
|
Raises:
|
|
JailNotFoundError: If *name* is not a known jail.
|
|
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
|
"""
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
|
|
|
# Verify existence.
|
|
try:
|
|
_ok(await client.send(["status", name, "short"]))
|
|
except ValueError as exc:
|
|
if _is_not_found_error(exc):
|
|
raise JailNotFoundError(name) from exc
|
|
raise
|
|
|
|
(
|
|
bantime_raw,
|
|
findtime_raw,
|
|
maxretry_raw,
|
|
failregex_raw,
|
|
ignoreregex_raw,
|
|
logpath_raw,
|
|
datepattern_raw,
|
|
logencoding_raw,
|
|
backend_raw,
|
|
actions_raw,
|
|
) = await asyncio.gather(
|
|
_safe_get(client, ["get", name, "bantime"], 600),
|
|
_safe_get(client, ["get", name, "findtime"], 600),
|
|
_safe_get(client, ["get", name, "maxretry"], 5),
|
|
_safe_get(client, ["get", name, "failregex"], []),
|
|
_safe_get(client, ["get", name, "ignoreregex"], []),
|
|
_safe_get(client, ["get", name, "logpath"], []),
|
|
_safe_get(client, ["get", name, "datepattern"], None),
|
|
_safe_get(client, ["get", name, "logencoding"], "UTF-8"),
|
|
_safe_get(client, ["get", name, "backend"], "polling"),
|
|
_safe_get(client, ["get", name, "actions"], []),
|
|
)
|
|
|
|
jail_cfg = JailConfig(
|
|
name=name,
|
|
ban_time=int(bantime_raw or 600),
|
|
find_time=int(findtime_raw or 600),
|
|
max_retry=int(maxretry_raw or 5),
|
|
fail_regex=_ensure_list(failregex_raw),
|
|
ignore_regex=_ensure_list(ignoreregex_raw),
|
|
log_paths=_ensure_list(logpath_raw),
|
|
date_pattern=str(datepattern_raw) if datepattern_raw else None,
|
|
log_encoding=str(logencoding_raw or "UTF-8"),
|
|
backend=str(backend_raw or "polling"),
|
|
actions=_ensure_list(actions_raw),
|
|
)
|
|
|
|
log.info("jail_config_fetched", jail=name)
|
|
return JailConfigResponse(jail=jail_cfg)
|
|
|
|
|
|
async def list_jail_configs(socket_path: str) -> JailConfigListResponse:
|
|
"""Return configuration for all active jails.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
|
|
Returns:
|
|
:class:`~app.models.config.JailConfigListResponse`.
|
|
|
|
Raises:
|
|
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
|
"""
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
|
|
|
global_status = _to_dict(_ok(await client.send(["status"])))
|
|
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
|
|
jail_names: list[str] = (
|
|
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
|
if jail_list_raw
|
|
else []
|
|
)
|
|
|
|
if not jail_names:
|
|
return JailConfigListResponse(jails=[], total=0)
|
|
|
|
responses: list[JailConfigResponse] = await asyncio.gather(
|
|
*[get_jail_config(socket_path, name) for name in jail_names],
|
|
return_exceptions=False,
|
|
)
|
|
|
|
jails = [r.jail for r in responses]
|
|
log.info("jail_configs_listed", count=len(jails))
|
|
return JailConfigListResponse(jails=jails, total=len(jails))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API — write jail configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def update_jail_config(
|
|
socket_path: str,
|
|
name: str,
|
|
update: JailConfigUpdate,
|
|
) -> None:
|
|
"""Apply *update* to the configuration of a running jail.
|
|
|
|
Each non-None field in *update* is sent as a separate ``set`` command.
|
|
Regex patterns are validated locally before any write is sent.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
name: Jail name.
|
|
update: Partial update payload.
|
|
|
|
Raises:
|
|
JailNotFoundError: If *name* is not a known jail.
|
|
ConfigValidationError: If a regex pattern fails to compile.
|
|
ConfigOperationError: If a ``set`` command is rejected by fail2ban.
|
|
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
|
"""
|
|
# Validate all regex patterns before touching the daemon.
|
|
for pattern_list, field in [
|
|
(update.fail_regex, "fail_regex"),
|
|
(update.ignore_regex, "ignore_regex"),
|
|
]:
|
|
if pattern_list is None:
|
|
continue
|
|
for pattern in pattern_list:
|
|
err = _validate_regex(pattern)
|
|
if err:
|
|
raise ConfigValidationError(f"Invalid regex in {field!r}: {err!r} (pattern: {pattern!r})")
|
|
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
|
|
|
# Verify existence.
|
|
try:
|
|
_ok(await client.send(["status", name, "short"]))
|
|
except ValueError as exc:
|
|
if _is_not_found_error(exc):
|
|
raise JailNotFoundError(name) from exc
|
|
raise
|
|
|
|
async def _set(key: str, value: Any) -> None:
|
|
try:
|
|
_ok(await client.send(["set", name, key, value]))
|
|
except ValueError as exc:
|
|
raise ConfigOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc
|
|
|
|
if update.ban_time is not None:
|
|
await _set("bantime", update.ban_time)
|
|
if update.find_time is not None:
|
|
await _set("findtime", update.find_time)
|
|
if update.max_retry is not None:
|
|
await _set("maxretry", update.max_retry)
|
|
if update.date_pattern is not None:
|
|
await _set("datepattern", update.date_pattern)
|
|
if update.dns_mode is not None:
|
|
await _set("usedns", update.dns_mode)
|
|
if update.enabled is not None:
|
|
await _set("idle", "off" if update.enabled else "on")
|
|
|
|
# Replacing regex lists requires deleting old entries then adding new ones.
|
|
if update.fail_regex is not None:
|
|
await _replace_regex_list(client, name, "failregex", update.fail_regex)
|
|
if update.ignore_regex is not None:
|
|
await _replace_regex_list(client, name, "ignoreregex", update.ignore_regex)
|
|
|
|
log.info("jail_config_updated", jail=name)
|
|
|
|
|
|
async def _replace_regex_list(
|
|
client: Fail2BanClient,
|
|
jail: str,
|
|
field: str,
|
|
new_patterns: list[str],
|
|
) -> None:
|
|
"""Replace the full regex list for *field* in *jail*.
|
|
|
|
Deletes all existing entries (highest index first to preserve ordering)
|
|
then inserts all *new_patterns* in order.
|
|
|
|
Args:
|
|
client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`.
|
|
jail: Jail name.
|
|
field: Either ``"failregex"`` or ``"ignoreregex"``.
|
|
new_patterns: Replacement list (may be empty to clear).
|
|
"""
|
|
# Determine current count.
|
|
current_raw = await _safe_get(client, ["get", jail, field], [])
|
|
current: list[str] = _ensure_list(current_raw)
|
|
|
|
del_cmd = f"del{field}"
|
|
add_cmd = f"add{field}"
|
|
|
|
# Delete in reverse order so indices stay stable.
|
|
for idx in range(len(current) - 1, -1, -1):
|
|
with contextlib.suppress(ValueError):
|
|
_ok(await client.send(["set", jail, del_cmd, idx]))
|
|
|
|
# Add new patterns.
|
|
for pattern in new_patterns:
|
|
err = _validate_regex(pattern)
|
|
if err:
|
|
raise ConfigValidationError(f"Invalid regex: {err!r} (pattern: {pattern!r})")
|
|
try:
|
|
_ok(await client.send(["set", jail, add_cmd, pattern]))
|
|
except ValueError as exc:
|
|
raise ConfigOperationError(f"Failed to add {field} pattern: {exc}") from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API — global configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def get_global_config(socket_path: str) -> GlobalConfigResponse:
|
|
"""Return fail2ban global configuration settings.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
|
|
Returns:
|
|
:class:`~app.models.config.GlobalConfigResponse`.
|
|
|
|
Raises:
|
|
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
|
"""
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
|
|
|
(
|
|
log_level_raw,
|
|
log_target_raw,
|
|
db_purge_age_raw,
|
|
db_max_matches_raw,
|
|
) = await asyncio.gather(
|
|
_safe_get(client, ["get", "loglevel"], "INFO"),
|
|
_safe_get(client, ["get", "logtarget"], "STDOUT"),
|
|
_safe_get(client, ["get", "dbpurgeage"], 86400),
|
|
_safe_get(client, ["get", "dbmaxmatches"], 10),
|
|
)
|
|
|
|
return GlobalConfigResponse(
|
|
log_level=str(log_level_raw or "INFO").upper(),
|
|
log_target=str(log_target_raw or "STDOUT"),
|
|
db_purge_age=int(db_purge_age_raw or 86400),
|
|
db_max_matches=int(db_max_matches_raw or 10),
|
|
)
|
|
|
|
|
|
async def update_global_config(socket_path: str, update: GlobalConfigUpdate) -> None:
|
|
"""Apply *update* to fail2ban global settings.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
update: Partial update payload.
|
|
|
|
Raises:
|
|
ConfigOperationError: If a ``set`` command is rejected.
|
|
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
|
"""
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
|
|
|
async def _set_global(key: str, value: Any) -> None:
|
|
try:
|
|
_ok(await client.send(["set", key, value]))
|
|
except ValueError as exc:
|
|
raise ConfigOperationError(f"Failed to set global {key!r} = {value!r}: {exc}") from exc
|
|
|
|
if update.log_level is not None:
|
|
await _set_global("loglevel", update.log_level.upper())
|
|
if update.log_target is not None:
|
|
await _set_global("logtarget", update.log_target)
|
|
if update.db_purge_age is not None:
|
|
await _set_global("dbpurgeage", update.db_purge_age)
|
|
if update.db_max_matches is not None:
|
|
await _set_global("dbmaxmatches", update.db_max_matches)
|
|
|
|
log.info("global_config_updated")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API — regex tester (stateless, no socket)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_regex(request: RegexTestRequest) -> RegexTestResponse:
|
|
"""Test a regex pattern against a sample log line.
|
|
|
|
This is a pure in-process operation — no socket communication occurs.
|
|
|
|
Args:
|
|
request: The :class:`~app.models.config.RegexTestRequest` payload.
|
|
|
|
Returns:
|
|
:class:`~app.models.config.RegexTestResponse` with match result.
|
|
"""
|
|
try:
|
|
compiled = re.compile(request.fail_regex)
|
|
except re.error as exc:
|
|
return RegexTestResponse(matched=False, groups=[], error=str(exc))
|
|
|
|
match = compiled.search(request.log_line)
|
|
if match is None:
|
|
return RegexTestResponse(matched=False)
|
|
|
|
groups: list[str] = list(match.groups() or [])
|
|
return RegexTestResponse(matched=True, groups=[str(g) for g in groups if g is not None])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API — log observation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def add_log_path(
|
|
socket_path: str,
|
|
jail: str,
|
|
req: AddLogPathRequest,
|
|
) -> None:
|
|
"""Add a log path to an existing jail.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
jail: Jail name to which the log path should be added.
|
|
req: :class:`~app.models.config.AddLogPathRequest` with the path to add.
|
|
|
|
Raises:
|
|
JailNotFoundError: If *jail* is not a known jail.
|
|
ConfigOperationError: If the command is rejected by fail2ban.
|
|
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
|
"""
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
|
|
|
try:
|
|
_ok(await client.send(["status", jail, "short"]))
|
|
except ValueError as exc:
|
|
if _is_not_found_error(exc):
|
|
raise JailNotFoundError(jail) from exc
|
|
raise
|
|
|
|
tail_flag = "tail" if req.tail else "head"
|
|
try:
|
|
_ok(await client.send(["set", jail, "addlogpath", req.log_path, tail_flag]))
|
|
log.info("log_path_added", jail=jail, path=req.log_path)
|
|
except ValueError as exc:
|
|
raise ConfigOperationError(f"Failed to add log path {req.log_path!r}: {exc}") from exc
|
|
|
|
|
|
async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse:
|
|
"""Read the last *num_lines* of a log file and test *fail_regex* against each.
|
|
|
|
This operation reads from the local filesystem — no socket is used.
|
|
|
|
Args:
|
|
req: :class:`~app.models.config.LogPreviewRequest`.
|
|
|
|
Returns:
|
|
:class:`~app.models.config.LogPreviewResponse` with line-by-line results.
|
|
"""
|
|
# Validate the regex first.
|
|
try:
|
|
compiled = re.compile(req.fail_regex)
|
|
except re.error as exc:
|
|
return LogPreviewResponse(
|
|
lines=[],
|
|
total_lines=0,
|
|
matched_count=0,
|
|
regex_error=str(exc),
|
|
)
|
|
|
|
path = Path(req.log_path)
|
|
if not path.is_file():
|
|
return LogPreviewResponse(
|
|
lines=[],
|
|
total_lines=0,
|
|
matched_count=0,
|
|
regex_error=f"File not found: {req.log_path!r}",
|
|
)
|
|
|
|
# Read the last num_lines lines efficiently.
|
|
try:
|
|
raw_lines = await asyncio.get_event_loop().run_in_executor(
|
|
None,
|
|
_read_tail_lines,
|
|
str(path),
|
|
req.num_lines,
|
|
)
|
|
except OSError as exc:
|
|
return LogPreviewResponse(
|
|
lines=[],
|
|
total_lines=0,
|
|
matched_count=0,
|
|
regex_error=f"Cannot read file: {exc}",
|
|
)
|
|
|
|
result_lines: list[LogPreviewLine] = []
|
|
matched_count = 0
|
|
for line in raw_lines:
|
|
m = compiled.search(line)
|
|
groups = [str(g) for g in (m.groups() or []) if g is not None] if m else []
|
|
result_lines.append(LogPreviewLine(line=line, matched=(m is not None), groups=groups))
|
|
if m:
|
|
matched_count += 1
|
|
|
|
return LogPreviewResponse(
|
|
lines=result_lines,
|
|
total_lines=len(result_lines),
|
|
matched_count=matched_count,
|
|
)
|
|
|
|
|
|
def _read_tail_lines(file_path: str, num_lines: int) -> list[str]:
|
|
"""Read the last *num_lines* from *file_path* synchronously.
|
|
|
|
Uses a memory-efficient approach that seeks from the end of the file.
|
|
|
|
Args:
|
|
file_path: Absolute path to the log file.
|
|
num_lines: Number of lines to return.
|
|
|
|
Returns:
|
|
A list of stripped line strings.
|
|
"""
|
|
chunk_size = 8192
|
|
raw_lines: list[bytes] = []
|
|
with open(file_path, "rb") as fh:
|
|
fh.seek(0, 2) # seek to end
|
|
end_pos = fh.tell()
|
|
if end_pos == 0:
|
|
return []
|
|
buf = b""
|
|
pos = end_pos
|
|
while len(raw_lines) <= num_lines and pos > 0:
|
|
read_size = min(chunk_size, pos)
|
|
pos -= read_size
|
|
fh.seek(pos)
|
|
chunk = fh.read(read_size)
|
|
buf = chunk + buf
|
|
raw_lines = buf.split(b"\n")
|
|
# Strip incomplete leading line unless we've read the whole file.
|
|
if pos > 0 and len(raw_lines) > 1:
|
|
raw_lines = raw_lines[1:]
|
|
return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Map color thresholds
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def get_map_color_thresholds(db: aiosqlite.Connection) -> MapColorThresholdsResponse:
|
|
"""Retrieve the current map color threshold configuration.
|
|
|
|
Args:
|
|
db: Active aiosqlite connection to the application database.
|
|
|
|
Returns:
|
|
A :class:`MapColorThresholdsResponse` containing the three threshold values.
|
|
"""
|
|
high, medium, low = await setup_service.get_map_color_thresholds(db)
|
|
return MapColorThresholdsResponse(
|
|
threshold_high=high,
|
|
threshold_medium=medium,
|
|
threshold_low=low,
|
|
)
|
|
|
|
|
|
async def update_map_color_thresholds(
|
|
db: aiosqlite.Connection,
|
|
update: MapColorThresholdsUpdate,
|
|
) -> None:
|
|
"""Update the map color threshold configuration.
|
|
|
|
Args:
|
|
db: Active aiosqlite connection to the application database.
|
|
update: The new threshold values.
|
|
|
|
Raises:
|
|
ValueError: If validation fails (thresholds must satisfy high > medium > low).
|
|
"""
|
|
await setup_service.set_map_color_thresholds(
|
|
db,
|
|
threshold_high=update.threshold_high,
|
|
threshold_medium=update.threshold_medium,
|
|
threshold_low=update.threshold_low,
|
|
)
|