diff --git a/README.md b/README.md index b4b941e..5168a71 100644 --- a/README.md +++ b/README.md @@ -316,22 +316,31 @@ stan kanałów bramki, „Połącz WhatsApp” (QR z WAHA odświeżany co 20 s d `BRAMKA_KLUCZ_ADMIN` (wysyłka powiadomień idzie osobnym `BRAMKA_KLUCZ`); przeglądarka nie zna żadnego z nich. -Odbiorcy (najwyżej 5): numer z kierunkowym, etykieta (≤ 40 znaków) i -przełącznik „aktywny”; „+ Dodaj odbiorcę”, „Usuń”, „Cofnij zmiany”. Zmiany +Odbiorcy (najwyżej 5): numer z kierunkowym, etykieta (≤ 40 znaków), kolumna +**„Dostaje”** i przełącznik „aktywny”; „+ Dodaj odbiorcę”, „Usuń”, „Cofnij zmiany”. Zmiany są lokalne, dopóki nie klikniesz „Zapisz listę” — zapis **całej** listy wymaga hasła albo kodu 2FA (lista jest wspólna dla wszystkich aplikacji RenaCode, sama sesja nie wystarcza). Błędne potwierdzenie liczy się do blokady konta; błąd w liście (zły numer, powtórka) odpada wcześniej, z 400, bez sprawdzania -hasła. „Wyślij test do aktywnych” wysyła próbę tylko WhatsAppem i pokazuje, -do ilu doszła (np. „doszło do 1 z 2”, z zamaskowanym numerem tego, do kogo +hasła. „Wyślij test (N)” wysyła próbę tylko WhatsAppem do aktywnych +z „Wszystko” i pokazuje, do ilu doszła (np. „doszło do 1 z 2”, z zamaskowanym numerem tego, do kogo nie doszło). -Bramka wysyła do wszystkich aktywnych naraz; gdy dojdzie do części, to +**„Dostaje”** to trasy w bramce (`charts/bramka/README.md` w renacode-infra, +„Trasy”): „Wszystko” — każda aplikacja RenaCode (kidwatch, trader, +monitoring…), alarmy techniczne czujki kidwatch i wiadomość próbna; +„Tylko kidwatch (dzieci)” — zwykłe powiadomienia kidwatch, **bez** alarmów +czujki (kidwatch wysyła je z `kategoria: czujka`). Nowy wiersz ma domyślnie +„Tylko kidwatch”. Alarm, którego nikt nie dostaje WhatsAppem, idzie e-mailem +na adres właściciela bramki. Przy bramce sprzed tras kolumna pokazuje +„wszystko”, a zapis nie wysyła pola tras. + +Bramka wysyła do wszystkich aktywnych z daną trasą naraz; gdy dojdzie do części, to sukces bez maila, a e-mail idzie dopiero, gdy nie doszło do nikogo. Numery widzi tylko zalogowany właściciel konta w Profilu; logi panelu i bramki mają wyłącznie trzy ostatnie cyfry (`...200`). Endpoint: `POST /api/profile/whatsapp/recipients` -`{"recipients": [{"number", "label", "active"}], "confirm": ""}` +`{"recipients": [{"number", "label", "active", "sources"?: ["*"] | ["kidwatch"]}], "confirm": ""}` (stary `POST /api/profile/whatsapp/recipient` z jednym numerem zostaje). Wymaga bramki z `POST /v1/whatsapp/odbiorcy` — przy starszej bramce Profil pokazuje jej jedynego odbiorcę, ale zapis listy kończy się błędem. diff --git a/src/kidwatch/bramka_admin.py b/src/kidwatch/bramka_admin.py index bbf06c8..6a9d0f3 100644 --- a/src/kidwatch/bramka_admin.py +++ b/src/kidwatch/bramka_admin.py @@ -33,6 +33,11 @@ #: odrzuca wczesniej, z komunikatem po polsku, ale decyduje bramka. MAX_RECIPIENTS = 5 MAX_LABEL = 40 +#: Trasy odbiorcy (kanaly.zrodla_z_wpisu w bramce): "*" = wszystko, inaczej +#: nazwy aplikacji z opcjonalnym ":kategoria". Te same granice co w bramce. +ALL_SOURCES = "*" +MAX_SOURCES = 10 +_SOURCE = re.compile(r"[a-z0-9][a-z0-9-]{0,39}(:[a-z0-9][a-z0-9-]{0,39})?", re.ASCII) class BramkaError(RuntimeError): @@ -60,10 +65,26 @@ def mask(number: str) -> str: return f"...{number[-3:]}" +def normalize_sources(raw: object, i: int) -> list[str]: + """["kidwatch"] / ["*"] -> lista bez powtorzen; "*" pochlania reszte.""" + if not isinstance(raw, list) or not raw: + raise ValueError(f"Odbiorca {i}: wybierz, co ma dostawać") + if len(raw) > MAX_SOURCES: + raise ValueError(f"Odbiorca {i}: najwyżej {MAX_SOURCES} źródeł") + result: list[str] = [] + for s in raw: + if not isinstance(s, str) or not (s == ALL_SOURCES or _SOURCE.fullmatch(s)): + raise ValueError(f"Odbiorca {i}: nieznane źródło powiadomień") + if s not in result: + result.append(s) + return [ALL_SOURCES] if ALL_SOURCES in result else result + + def normalize_recipients(raw: object) -> list[dict]: - """Lista z przegladarki [{number, label, active}] -> lista dla bramki - [{numer, etykieta, aktywny}]. ValueError z komunikatem dla czlowieka - - pozycja na liscie, nigdy sam numer.""" + """Lista z przegladarki [{number, label, active, sources?}] -> lista dla + bramki [{numer, etykieta, aktywny, zrodla?}]. Bez `sources` pole `zrodla` + nie idzie, a bramka zostawia trasy numeru bez zmian. ValueError + z komunikatem dla czlowieka - pozycja na liscie, nigdy sam numer.""" if not isinstance(raw, list): raise ValueError("Lista odbiorców ma zły format") if len(raw) > MAX_RECIPIENTS: @@ -87,7 +108,10 @@ def normalize_recipients(raw: object) -> list[dict]: raise ValueError(f"Odbiorca {i}: zła wartość „aktywny”") if any(r["numer"] == number for r in result): raise ValueError(f"Odbiorca {i}: ten numer jest już na liście") - result.append({"numer": number, "etykieta": label, "aktywny": active}) + entry = {"numer": number, "etykieta": label, "aktywny": active} + if "sources" in item: + entry["zrodla"] = normalize_sources(item["sources"], i) + result.append(entry) return result diff --git a/src/kidwatch/notifiers/bramka.py b/src/kidwatch/notifiers/bramka.py index ad3fc09..8c78d89 100644 --- a/src/kidwatch/notifiers/bramka.py +++ b/src/kidwatch/notifiers/bramka.py @@ -11,6 +11,11 @@ `priorytet`, `rodzaj` i `format` steruja tylko wygladem maila HTML (etykieta alarm / wazne / informacja, podpis w naglowku, listy i sekcje z tekstu). Starsza bramka ich nie czyta i wysyla jak dotad - pola sa zgodne wstecz. + +`kategoria` steruje TRASA w bramce: alarmy czujki ida jako "kidwatch:czujka", +a odbiorca z trasa "kidwatch" (rodzina) dostaje tylko zwykle powiadomienia +o dzieciach. Starsza bramka pole ignoruje - wtedy czujka idzie do wszystkich, +jak dotad. """ from __future__ import annotations @@ -45,6 +50,12 @@ NotifyKind.GAME: "czas gry", } +#: Rodzaje, ktore nie sa powiadomieniem o dzieciach, tylko alarmem +#: technicznym (cisza DNS, odczyt iPada nie dziala, kolejka porzucila push). +#: Bramka kieruje je tylko do odbiorcow z "*" - patrz charts/bramka/README.md, +#: "Trasy". +KATEGORIE = {NotifyKind.WATCHDOG: "czujka"} + class BramkaNotifier: name = "bramka" @@ -67,6 +78,7 @@ def url(self) -> str: return self.cfg.url.rstrip("/") + "/v1/wyslij" def payload(self, note: Notification) -> dict: + extra = {"kategoria": KATEGORIE[note.kind]} if note.kind in KATEGORIE else {} return { "temat": note.title, "tresc": note.text, @@ -76,6 +88,7 @@ def payload(self, note: Notification) -> dict: "rodzaj": RODZAJE.get(note.kind, ""), # Tresc z formatting.py: punkty "•", naglowki sekcji w *gwiazdkach*. "format": "markdown-lite", + **extra, } async def send(self, note: Notification) -> bool: diff --git a/src/kidwatch/panel.py b/src/kidwatch/panel.py index 6d424a7..7f0c317 100644 --- a/src/kidwatch/panel.py +++ b/src/kidwatch/panel.py @@ -1152,7 +1152,9 @@ def _profile_post(self, path: str) -> None: "panel: %s zapisal odbiorcow WhatsApp (%d, aktywni %d): %s", session.login, len(recipients), sum(r["aktywny"] for r in recipients), - ", ".join(mask(r["numer"]) + ("" if r["aktywny"] else " (wyl.)") + ", ".join(mask(r["numer"]) + + (f" [{','.join(r['zrodla'])}]" if "zrodla" in r else "") + + ("" if r["aktywny"] else " (wyl.)") for r in recipients) or "-", ) elif path == "/api/profile/test": diff --git a/tests/test_notifiers.py b/tests/test_notifiers.py index c579475..9bae507 100644 --- a/tests/test_notifiers.py +++ b/tests/test_notifiers.py @@ -336,6 +336,23 @@ def handler(request: httpx.Request) -> httpx.Response: await client.aclose() +async def test_bramka_czujka_idzie_z_kategoria_a_zwykle_bez(): + """Trasy w bramce: alarm techniczny (WATCHDOG) to "kidwatch:czujka", ktorej + rodzina z trasa "kidwatch" nie dostaje. Zwykle powiadomienia bez kategorii.""" + from kidwatch.config import BramkaConfig + from kidwatch.notifiers.bramka import BramkaNotifier + + n = BramkaNotifier(BramkaConfig(url="http://bramka.test/"), key="k1", + client=httpx.AsyncClient()) + ts = datetime.now(UTC) + assert n.payload(Notification(kind=NotifyKind.WATCHDOG, title="Cisza DNS", text="x", + dedup_key="w", ts=ts))["kategoria"] == "czujka" + for kind in set(NotifyKind) - {NotifyKind.WATCHDOG}: + body = n.payload(Notification(kind=kind, title="t", text="x", dedup_key="d", ts=ts)) + assert "kategoria" not in body, kind + await n.aclose() + + def test_bramka_kazdy_rodzaj_ma_podpis_w_mailu(): from kidwatch.notifiers.bramka import RODZAJE diff --git a/tests/test_profile.py b/tests/test_profile.py index 953c712..3586461 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -376,6 +376,34 @@ def test_lista_odbiorcow_dla_bramki(): assert normalize_recipients([]) == [] +def test_trasy_odbiorcow_dla_bramki(): + """`sources` z panelu -> `zrodla` dla bramki; bez pola bramka zostawia + trasy numeru (stary panel nie poszerza nikomu tras).""" + assert normalize_recipients([ + {"number": A, "label": "Ja", "active": True, "sources": ["*", "kidwatch"]}, + {"number": B, "label": "Rodzina", "active": True, "sources": ["kidwatch", "kidwatch"]}, + {"number": C, "active": True, "sources": ["kidwatch", "kidwatch:czujka"]}, + ]) == [ + {"numer": A, "etykieta": "Ja", "aktywny": True, "zrodla": ["*"]}, + {"numer": B, "etykieta": "Rodzina", "aktywny": True, "zrodla": ["kidwatch"]}, + {"numer": C, "etykieta": "", "aktywny": True, "zrodla": ["kidwatch", "kidwatch:czujka"]}, + ] + assert "zrodla" not in normalize_recipients([{"number": A}])[0] + + +@pytest.mark.parametrize("sources, fragment", [ + ([], "co ma dostawać"), + ("*", "co ma dostawać"), + (["Trader"], "nieznane"), + ([1], "nieznane"), + ([f"a{i}" for i in range(11)], "najwyżej 10"), +]) +def test_walidacja_tras(sources, fragment): + with pytest.raises(ValueError, match=fragment) as e: + normalize_recipients([{"number": A, "sources": sources}]) + assert "Odbiorca 1" in str(e.value) and A not in str(e.value) + + @pytest.mark.parametrize("lista, fragment", [ (None, "format"), ([{"number": f"4850010030{i}"} for i in range(6)], "Najwyżej 5"), @@ -430,6 +458,14 @@ def test_zapis_listy_odbiorcow_wymaga_hasla(served, caplog): status, data = call(port, "GET", "/api/profile/notify", headers={"Cookie": h["Cookie"]}) assert status == 200 and len(data["odbiorcy"]) == 3 + # Trasy z Profilu ida do bramki jako `zrodla`, a log pokazuje, kto co dostaje. + z_trasami = [{**LISTA[0], "sources": ["*"]}, {**LISTA[1], "sources": ["kidwatch"]}] + with caplog.at_level("INFO", logger=panel_mod.log.name): + status, data = call(port, "POST", url, {"recipients": z_trasami, "confirm": HASLO}, h) + assert status == 200, data + assert [o["zrodla"] for o in calls[-1][2]["odbiorcy"]] == [["*"], ["kidwatch"]] + assert "...200 [*], ...300 [kidwatch]" in " ".join(r.getMessage() for r in caplog.records) + def test_zapis_listy_odbiorcow_kodem_2FA(served): import pyotp # noqa: PLC0415 diff --git a/web/src/components/Profile.jsx b/web/src/components/Profile.jsx index 64114d3..a079b19 100644 --- a/web/src/components/Profile.jsx +++ b/web/src/components/Profile.jsx @@ -5,8 +5,9 @@ import React, { useCallback, useEffect, useState } from 'react'; import { get, post } from '../utils/api'; import { - MAX_LABEL, MAX_RECIPIENTS, partialFailure, recipientsFromState, recipientsPayload, - sameRecipients, testSummary, validateRecipients, + MAX_LABEL, MAX_RECIPIENTS, SOURCES_ALL, SOURCES_FAMILY, getsEverything, newRecipient, + partialFailure, recipientsFromState, recipientsPayload, sameRecipients, sourcesKey, + sourcesLabel, testSummary, validateRecipients, } from '../utils/recipients'; import TwoFactor from './TwoFactor'; @@ -78,6 +79,27 @@ function PasswordForm() { ); } +/* Co dostaje odbiorca (trasy w bramce). Dwa gotowe wybory; trasy ustawione + inaczej (API bramki) widac jako trzecia pozycje i zostaja bez zmian. */ +function SourcesSelect({ i, sources, apps, onChange }) { + if (!Array.isArray(sources)) { + return wszystko; + } + const others = apps.filter((a) => a !== 'kidwatch'); + const key = sourcesKey(sources); + const presets = [sourcesKey(SOURCES_ALL), sourcesKey(SOURCES_FAMILY)]; + return ( + + ); +} + /* Odbiorcy WhatsApp: cala lista edytowana lokalnie i zapisywana jednym POST. Lista jest wspolna dla wszystkich aplikacji RenaCode - serwer zada hasla albo kodu 2FA (sama sesja nie wystarcza). */ @@ -91,6 +113,8 @@ function Recipients({ state, onSaved, busy, setBusy }) { const dirty = !sameRecipients(draft, saved); const problem = validateRecipients(draft, max, maxLabel); const activeSaved = saved.filter((r) => r.active).length; + const testTargets = saved.filter((r) => r.active && getsEverything(r)).length; + const apps = Array.isArray(state.aplikacje) ? state.aplikacje : []; // Odswiezenie stanu (np. co 20 s przy QR) nie nadpisuje edycji w toku. const savedKey = JSON.stringify(recipientsPayload(saved)); @@ -147,7 +171,7 @@ function Recipients({ state, onSaved, busy, setBusy }) {
- + {draft.map((r, i) => ( @@ -162,6 +186,10 @@ function Recipients({ state, onSaved, busy, setBusy }) { aria-label={`Etykieta odbiorcy ${i + 1}`} value={r.label} onChange={(e) => edit(i, { label: e.target.value })} /> +
Numer (z kierunkowym)EtykietaAktywny
Numer (z kierunkowym)EtykietaDostajeAktywny
+ edit(i, { sources })} /> +