10970c579f
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m19s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m26s
Testy / Testy astrodemo (pull_request) Failing after 0s
Testy / Testy astroklient (pull_request) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m26s
Testy / Testy astrodemo (push) Failing after 0s
Testy / Testy astroklient (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Trzeci produkt drabiny: astrodemo (dwie funkcje) → astroklient → astrololo. Po trzech poprzednich krokach jest cienki, bo jest ZŁOŻENIEM, a nie kopią: własne main.py z ośmioma importami, a rdzeń — ekrany, szablony, zasoby — bierze z warstwy prezentacji przy budowaniu obrazu. Jedno źródło, dwa produkty; inaczej te same 2500 linii szablonów żyłyby w dwóch egzemplarzach i rozjechały się w ciągu tygodni, po cichu. CO MA: Horoskop, Interpretacje, Kalendarz, Synastria, Sygnifikatory, wgrywanie plików. Wyszukiwarka miejsca i strefa czasowa zgodnie z ustaleniem. CZEGO NIE MA I DLACZEGO NIE DA SIĘ WŁĄCZYĆ: plików usuniętych wg usun.txt nie ma w obrazie. Nie istnieje uprawnienie, którym dałoby się je odsłonić, bo katalog funkcji składa się ze ZGŁOSZEŃ ekranów obecnych w obrazie. To dlatego „każde konto dostaje wszystko, co ta usługa umie" jest tu bezpieczne i nie wymaga wypisywania listy: zbiór liczy się z katalogu, więc opisuje ten produkt. KONTA jak w astrodemo: z konfiguracji środowiska (ASTROKLIENT_USERS), jeden poziom dostępu, bez pliku kont i bez ekranu ich zakładania. Konta rozdziela się po to, żeby każde miało własną pulę plików. PULE PER KONTO — tu była realna dziura. Warstwa logiczna przenosiła pulę tylko przy raporcie i operacjach na plikach, więc Kalendarz i Sygnifikatory czytałyby CAŁY udział: jedno konto widziałoby pliki drugiego, mimo obietnicy izolacji. Domknięte: TimelineRequest i QueryRequest niosą teraz pulę, a QueryService buduje klienta danych na żądanie. Pula jedzie w każdym żądaniu w dół i bierze się z kontekstu ustawianego przy wejściu, nigdy z formularza. Test podstawia `tenant=ktos-inny` w POST i sprawdza, że w dół poszedł login zalogowanego. WARSTWA WSPÓLNA ROZDZIELONA OD POJĘCIA ADMINISTRATORA. base.html miał wpisany na sztywno warunek `can(request, 'admin')` i odsyłacz do ekranu kont — czyli w produkcie bez tego ekranu zostawał martwy link i nazwa czegoś, czego nie ma. Rejestr niesie teraz wymagane uprawnienie, a szablon dostaje gotową listę. Podstawa przestała też importować moduły służące jednemu ekranowi (konta, stany plików), bo produkt bez tego ekranu wlókł ich zależności. ZAPORA SŁOWNIKOWA NAD REALNYM DRZEWEM. Test buduje złożenie tak samo jak Dockerfile i szuka słów o funkcjach, których nie ma — w odpowiedziach ORAZ w plikach. Pierwsza wersja znalazła dziesięć trafień, w tym trzy moje własne docstringi WYLICZAJĄCE nieobecne funkcje: zdanie „nie ma tu generowania tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak przycisk. Po poprawkach: zero. Test ma kontrolę negatywną — podrzucony plik ma go wywrócić. usun.txt jest DANYMI, nie tekstem w Dockerfile: czyta go też test pilnujący, żeby zgadzał się ze złożeniem w main.py. Rozjazd znaczyłby albo martwy kod w obrazie, albo błąd dopiero przy uruchomieniu. CI: astroklient buduje się z KORZENIA repozytorium (jego Dockerfile sięga po rdzeń), a zmiana w warstwie prezentacji też go przebudowuje — bez tego jego obraz zostawałby ze starymi ekranami, a różnicy nie byłoby widać do zgłoszenia użytkownika. Testy: astroklient 12, presentation 368, astrodemo 28, logic 342, data 42, render 41. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
8.6 KiB
Python
175 lines
8.6 KiB
Python
"""Klient warstwy logicznej — z PULĄ KONTA (PRE-29).
|
|
|
|
Każde konto ma własny, niewidoczny dla pozostałych zbiór plików. Pula jedzie
|
|
w każdym żądaniu w dół i bierze się WYŁĄCZNIE z kontekstu ustawianego przy
|
|
wejściu, nigdy z pola formularza: gdyby przychodziła z żądania, wystarczyłoby
|
|
podstawić cudzy login, żeby czytać cudze pliki.
|
|
|
|
Kontekst zamiast argumentu, bo ekrany są wspólne z pełną aplikacją i wołają
|
|
klienta jako obiekt modułowy. Przekazywanie puli przez wszystkie wywołania
|
|
znaczyłoby, że pominięcie jednego z nich cicho otwiera dostęp do całego udziału —
|
|
a takiego pominięcia nie widać.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import os
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
# Login zalogowanego konta na czas obsługi żądania. Ustawia go bramka wejścia.
|
|
KONTO: contextvars.ContextVar[str] = contextvars.ContextVar("konto", default="")
|
|
|
|
|
|
from app import link_crypto
|
|
from app.config import settings
|
|
|
|
|
|
def _auth_headers() -> dict[str, str]:
|
|
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
|
|
token = os.getenv("INTERNAL_TOKEN", "")
|
|
return {"X-Astrololo-Token": token} if token else {}
|
|
|
|
|
|
def _link() -> link_crypto.Link | None:
|
|
"""Klucz łącza prezentacja↔logika. Czytany przy każdym wywołaniu, bo
|
|
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
|
|
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
|
|
return link_crypto.Link(key) if key else None
|
|
|
|
|
|
class LogicClient:
|
|
def __init__(self, base_url: str | None = None) -> None:
|
|
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
|
|
|
def _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
# Pula konta dokładana do KAŻDEGO żądania w dół. Bierze się z kontekstu
|
|
# ustawianego przy wejściu, nigdy z pola formularza — gdyby przychodziła
|
|
# z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze pliki.
|
|
# Trasy, których pula nie dotyczy, po prostu ignorują nadmiarowe pole.
|
|
payload = {**payload, "tenant": KONTO.get("")}
|
|
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
|
|
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
|
|
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
|
|
with httpx.Client(timeout=timeout) as client:
|
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
|
payload=payload, headers=_auth_headers(),
|
|
link=_link())
|
|
|
|
# ── transport dla modułów opcjonalnych ─────────────────────────────────
|
|
# Moduł nie może dopisywać sobie metod do tej klasy: jego nazwy siedziałyby
|
|
# wtedy w pliku, który jedzie do KAŻDEGO obrazu. Dostaje więc samą drogę
|
|
# w dół, a co nią wysyła, jest jego sprawą. Szyfrowanie łącza i token
|
|
# międzywarstwowy obowiązują tak samo — to nie jest obejście ochrony.
|
|
def wywolaj(self, sciezka: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
return self._post(sciezka, payload, timeout)
|
|
|
|
def pobierz(self, sciezka: str, timeout: float | None = None) -> dict[str, Any]:
|
|
with httpx.Client(timeout=timeout or settings.http_timeout) as client:
|
|
return link_crypto.call_json(client, "GET", f"{self.base_url}{sciezka}",
|
|
headers=_auth_headers(), link=_link())
|
|
|
|
def strumien(self, sciezka: str, payload: dict[str, Any]):
|
|
"""Strumień NDJSON w dół — przekazywany dalej ramka po ramce.
|
|
|
|
Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy
|
|
włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie."""
|
|
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
|
|
yield from link_crypto.stream_lines(
|
|
client, f"{self.base_url}{sciezka}",
|
|
payload=payload, headers=_auth_headers(), link=_link())
|
|
|
|
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
|
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
|
return self._post("/api/query", payload, settings.http_timeout)
|
|
|
|
def positions(
|
|
self,
|
|
when_utc_iso: str,
|
|
lat: float,
|
|
lon: float,
|
|
objects: list[str] | None = None,
|
|
house_system: str = "whole_sign",
|
|
stations: bool = False,
|
|
zodiac: str = "tropical",
|
|
tables: bool = False,
|
|
house_systems: list[str] | None = None,
|
|
aspect_orb: float = 8.0,
|
|
aspect_luminary_bonus: float = 2.0,
|
|
aspect_minor: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
|
payload = {
|
|
"when_utc": when_utc_iso,
|
|
"lat": lat,
|
|
"lon": lon,
|
|
"objects": objects,
|
|
"house_system": house_system,
|
|
"house_systems": house_systems,
|
|
"aspect_orb": aspect_orb,
|
|
"aspect_luminary_bonus": aspect_luminary_bonus,
|
|
"aspect_minor": aspect_minor,
|
|
"stations": stations,
|
|
"zodiac": zodiac,
|
|
"tables": tables,
|
|
}
|
|
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
|
|
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
|
|
return self._post("/chart/positions", payload, timeout)
|
|
|
|
def synastry(
|
|
self, person_a: dict, person_b: dict, zodiac: str = "tropical",
|
|
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0, aspect_minor: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Synastria dwóch osób (PRE-04) — aspekty między horoskopami."""
|
|
payload = {
|
|
"person_a": person_a, "person_b": person_b, "zodiac": zodiac,
|
|
"aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus,
|
|
"aspect_minor": aspect_minor,
|
|
}
|
|
return self._post("/chart/synastry", payload, settings.http_timeout)
|
|
|
|
def report(
|
|
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
|
) -> dict[str, Any]:
|
|
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
|
|
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
|
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
|
|
|
|
def bases(self) -> dict[str, Any]:
|
|
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
|
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
|
headers=_auth_headers(), link=_link())
|
|
|
|
|
|
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
|
|
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
|
|
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
|
|
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
|
|
|
|
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
|
|
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
|
|
|
|
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
|
|
return self._files_post("/files/upload",
|
|
{"filename": filename, "content_b64": content_b64, "by": by})
|
|
|
|
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
|
|
payload = {**payload, "tenant": KONTO.get("")}
|
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
|
payload=payload, headers=_auth_headers(), link=_link())
|
|
|
|
def timeline(
|
|
self, when_utc_iso: str, lat: float, lon: float,
|
|
from_date: str, to_date: str, interpret: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
|
|
payload = {
|
|
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
|
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
|
}
|
|
return self._post("/chart/timeline", payload, max(settings.http_timeout, 60.0))
|