Compare commits

..

6 Commits

Author SHA1 Message Date
gitea 338ec24e90 astroklient: własna nazwa produktu zamiast odziedziczonej po rdzeniu
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m20s
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) Successful in 9m25s
Testy / Testy astroklient (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 6s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
build / build (push) Successful in 12s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m21s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astrodemo (push) Successful in 9m25s
Testy / Testy astroklient (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Astroklient przedstawiał się na stronie głównej jako „astrololo" — w tytule
karty i w nagłówku. To najpoważniejszy z możliwych śladów: mówi klientowi
wprost, że trzyma okrojoną wersję czegoś większego, i podaje frazę, po której da
się wyszukać, czego mu nie dano. Cała reszta izolacji przestaje mieć znaczenie,
gdy produkt sam się przedstawia nazwą rodzica.

Wzięło się stąd, że nazwa była wpisana na sztywno w szablony, a szablony są
WSPÓLNE — dokładnie ta sama przyczyna, co przy zaszytym w base.html warunku na
administratora. Nazwa mieszka teraz w module `produkt.py`, który nakładka
produktu nadpisuje; szablony o nią pytają, zamiast ją znać.

astrodemo NIE było dotknięte: ma własne kopie szablonów i pokazuje „astrodemo".

DRUGIE ŹRÓDŁO WYCIEKU, mniej oczywiste: nazwa siedziała też w identyfikatorach
JavaScriptu, które widzi każdy, kto otworzy narzędzia deweloperskie — zdarzenie
`astrololo:coords` i klucz `astrololo.form.v1` w localStorage. Przemianowane na
neutralne `kosmogram:*`; te identyfikatory nie mają powodu nosić nazwy rodziny
produktów. SKUTEK UBOCZNY: zmiana klucza w localStorage kasuje raz zapamiętane
wersje robocze formularza.

Sprawdzone, że NIE wyciekają: nagłówki X-Astrololo-* jadą wyłącznie między
warstwami, a dokumentacja OpenAPI (gdzie zostaje tytuł aplikacji FastAPI) oddaje
404 także zalogowanemu.

TEST rozdziela to, co widzi człowiek, od identyfikatorów wewnętrznych. Nazwa
rdzenia wolno występować w nazwach loggerów, materiale do wyprowadzania kluczy
i nagłówkach międzywarstwowych; nie wolno w szablonach, zasobach ani w treści
odpowiedzi. Zakaz na wszystko byłby albo obejściem przez zmianę nazw kluczy
kryptograficznych, albo martwą regułą wyłączaną przy pierwszej okazji.

Sprawdzony w obie strony: po cofnięciu nakładki test pada.

Testy: astroklient 15, presentation 368, astrodemo 28, logic 342, data 42,
render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:37:03 +02:00
gitea 54b40857d2 ci: przywróć uruchamianie testów astrodemo i wykrywaj puste kroki
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m24s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astrodemo (pull_request) Successful in 9m25s
Testy / Testy astroklient (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
build / build (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m20s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m26s
Testy / Testy astrodemo (push) Successful in 9m25s
Testy / Testy astroklient (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Testy astrodemo padały na masterze, choć lokalnie przechodzą — bo w CI nie
uruchamiały się WCALE. Dopisując blok astroklienta rozciąłem blok sąsiada:
krok „Testy (pytest)" astrodemo został z samym `working-directory`, a jego
`env` i `run` przywędrowały do zadania astroklienta.

Skutek podwójny i gorszy, niż wygląda: astrodemo zgłaszało błąd (krok bez
polecenia), a astroklient przechodził Z NIEWŁAŚCIWEGO POWODU — na cudzym `run`.
Zielone CI, które nic nie uruchomiło, jest gorsze od czerwonego, bo nie ma czego
naprawiać.

Krok bez `run` i bez `uses` jest dla Gitei krokiem pustym. Dopisana kontrola
przechodzi wszystkie pliki workflow i przerywa, gdy taki krok się trafi albo
gdy zadanie nie ma kroków w ogóle. Sprawdzona w obie strony: przepuszcza obecny
stan i wykrywa dokładnie to rozcięcie, gdy je odtworzyć.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:59:36 +02:00
gitea 10970c579f astroklient: warstwa pośrednia — pełne astro, bez generowania i administracji (4/5)
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>
2026-08-26 20:40:38 +02:00
gitea cf24c4d473 ci: buduj tylko zmienione usługi i sprzątaj po sobie na runnerze
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m22s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m30s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astrodemo (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
build-render / build (push) Successful in 2m39s
build / build (push) Successful in 10s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m20s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astrodemo (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Rejestr obrazów rósł, bo `build.yaml` budował CZTERY obrazy przy każdym pushu do
mastera, niezależnie od tego, co się zmieniło. Zmiana w samej prezentacji dawała
nowe wersje data, logic i astrodemo — identyczne co do zawartości, różniące się
wyłącznie tagiem.

Render i silnik B miały filtry ścieżek od początku (build-render.yaml,
build-swisseph.yaml) i budują się tylko przy własnych zmianach. Ta zmiana
przenosi tę samą zasadę do głównego pipeline'u, licząc różnicę wobec poprzedniego
commita. Kontekstem budowania każdego obrazu jest wyłącznie `./services/<nazwa>`,
więc porównanie ścieżek jest dokładne, a nie przybliżone.

Sprawdzone na ośmiu ostatnich commitach mastera: dwa refaktory prezentacji dają
sam `presentation`, poprawka Dockerfile'a astrodemo daje samo `astrodemo`,
a commit ruszający link_crypto w pięciu usługach — komplet. Bez porównania
(pierwszy commit, przepisana historia) budujemy wszystko: lepiej zbudować za
dużo niż wypuścić obraz ze starym kodem pod nowym tagiem.

SKUTEK UBOCZNY DO ŚWIADOMOŚCI: tagi w kustomization mogą się teraz rozjechać
między usługami, bo nie każda dostaje nową wersję z każdego commita. Tak ma być —
image-updater śledzi każdy obraz osobno i przypina to, co istnieje.

ZABEZPIECZENIE PRZED CICHYM POMINIĘCIEM. Usługa, której nie ma na żadnej liście,
nigdy się nie zbuduje i nikt tego nie zauważy: brak obrazu wygląda potem na
problem z rejestrem albo z siecią. Ten sam mechanizm kosztował nas już raz, przy
liście obserwowanych obrazów w image-updaterze. Pętla po `services/*/` zatrzymuje
budowanie i mówi wprost, co dopisać. Sprawdzone także od strony negatywnej —
podstawiony katalog nowej usługi kończy przebieg kodem 1.

SPRZĄTANIE NA RUNNERZE, w build.yaml i w build-render.yaml, z `if: always()` —
bo to po NIEUDANYM budowaniu zostaje najwięcej śmieci, a kolejny przebieg
zaczyna od mniejszego zapasu miejsca niż poprzedni. Tak zatkał się dysk przy
renderze z TeX Live. Filtr `until=168h` zostawia tydzień, więc warstwy bazowe
i cache przeżywają i build nie zaczyna od zera.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:13:27 +02:00
gitea fd79513ce2 astrololo: ekrany i eksport jako moduły, katalog z rejestracji (3/5)
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Failing after 3s
Testy / Testy astrodemo (pull_request) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 2s
Testy / Kontrola składni wszystkich warstw (pull_request) Failing after 3s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
build / build (push) Successful in 9s
Trzeci z pięciu kroków. Zmieniłem jego zakres wobec planu i warto wiedzieć
dlaczego: pierwotnie miała to być deduplikacja pięciu kopii link_crypto.py, ale
po zrobieniu PR 2 widać, że astroklienta blokuje co innego — ekrany, których nie
ma mieć, siedzą wewnątrz jednego main.py. Deduplikacja kryptografii jest realnym
długiem, ale niczego nie blokuje.

PODZIAŁ. main.py (1023 linie) rozpadł się na podstawa.py (wspólne obiekty
i pomocnicy), jedenaście modułów w app/ekrany/ i main.py, który jest już samym
ZŁOŻENIEM: lista importów JEST definicją produktu. Podział zrobiony mechanicznie,
z osobnym sprawdzeniem, że żadna sekcja nie wołała pomocnika z innej (nie wołała).

KATALOG Z REJESTRACJI. Dotąd wszystkie funkcje były wypisane w features.py, więc
obraz produktu, który części z nich nie ma, i tak niósł ich nazwy — spis funkcji,
których nie ma jak włączyć. Teraz ekran zgłasza siebie, swoje trasy i swoje
zasoby przy imporcie własnego modułu, a features.py nie wymienia ani jednego
ekranu. Kolejność w nawigacji jest jawna (`kolejnosc`), żeby nie rządziła nią
kolejność importów.

To samo dotyczy nawigacji administratora: odsyłacz do ekranu kont był wpisany na
sztywno w base.html, więc w węższym produkcie zostawał martwy link i nazwa
ekranu, którego nie ma.

EKSPORT JAKO MODUŁ. Zgodnie z ustaleniem eksport jest funkcją administracyjną,
więc musi dać się usunąć. app/moduly/eksport/ zabiera arkusz, trasę PDF-a i akcję
formularza. „Można, ale nie temu kontu" i „nie ma takiej możliwości" to dwie
różne gwarancje, a eksport wynosi najwięcej treści baz naraz.

MOST ODKRYWA MODUŁY. Skoro modułów jest więcej niż jeden, most nie może ich znać
z nazwy — nazwa nieobecnego modułu jechałaby do obrazu, w którym go nie ma.
Przechodzi więc po podkatalogach app/moduly/ i pyta każdy, co wnosi. Katalog
generowania przeniesiony z app/dodatki na app/moduly/dodatki.

ZNALEZIONE PRZY OKAZJI. Po wydzieleniu eksportu okazało się, że jego ścieżki
SZCZĘŚLIWEJ nie sprawdzał żaden test — badano wyłącznie odmowę dla konta bez
uprawnienia. Moduł dostaje zależności z wywołania montującego, więc brak jednej
z nich wyszedłby dopiero przy pierwszym kliknięciu. Dopisany test funkcjonalny
(realny arkusz, sprawdzany aż do nagłówka ZIP-a) i brakujące zależności.

Cztery komentarze w plikach współdzielonych wymieniały zakładkę „Skompiluj",
w tym wheelzoom.js ze wzmianką o „przyszłej zakładce" — to samo zgłoszenie, które
audyt podnosił wcześniej.

TEST ZŁOŻENIA. Buduje węższy produkt NAPRAWDĘ: kopiuje drzewo, usuwa cztery
ekrany i oba moduły, uruchamia aplikację w OSOBNYM PROCESIE (importy są
zapamiętywane, więc sprawdzanie tego w procesie, który moduł już zaimportował,
dawałoby wynik fałszywie pozytywny) i sprawdza, że wstaje, że zachowane ekrany
oddają 200, że usunięte oddają 404 (nie 403 i nie 500), że katalog opisuje ten
obraz, i że w nawigacji nie ma martwych odsyłaczy.

Napisałem najpierw ostrzejszy test — „nazwa ekranu nie pada poza jego modułem" —
i go wyrzuciłem: zgłaszał wzmianki o Horoskopie w plikach współdzielonych, choć
astroklient Horoskop MA. Ślad ma znaczenie wyłącznie wobec konkretnego złożenia,
więc sprawdzenie należy do produktu, nie do mechanizmu.

Testy: presentation 368, logic 342, data 37, render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:32:49 +02:00
gitea 62d6f9d4d5 astrololo: generowanie tekstu jako moduł odłączalny (2/5)
build / build (push) Failing after 2s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
Drugi z pięciu kroków budowy trzech produktów. Czysty refaktor — zachowanie
aplikacji się nie zmienia, liczba testów rośnie tylko o nowe.

DLACZEGO WARUNEK W SZABLONIE NIE WYSTARCZA. Dotąd generowanie było chowane przez
`{% if can(request, 'ai') %}`. To sprawia, że funkcji nie WIDAĆ, ale nie że jej
NIE MA: plik szablonu dalej leży w obrazie i dalej zawiera jej nazwy, więc `grep`
po kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie. Astroklient
ma nie mieć śladu, nie mieć wyłączoną funkcję — więc granica musi być KATALOGIEM.

CO SIĘ PRZENIOSŁO do app/dodatki/: dwa szablony, pięć plików statycznych, trasa
strumienia, cztery metody klienta warstwy logicznej, wpis w katalogu funkcji,
wpisy tras i zasobów, akcje formularza i reguły CSS nazywające funkcję
(`textarea.prompt`).

JAK APLIKACJA O TO PYTA. Neutralny most `app/rozszerzenia.py` odpowiada wyłącznie
na pytanie „czy coś jest podpięte i co wnosi". Sam nie wymienia ani jednej nazwy —
pilnuje tego osobny test, bo most jest w KAŻDYM obrazie. Dlatego też katalog
nazywa się `dodatki`, a nie `ai`: nazwa w instrukcji importu byłaby dokładnie tym
śladem, którego wydzielanie ma się pozbyć.

TRZY MIEJSCA, KTÓRE OKAZAŁY SIĘ TRUDNE:

1. Pola formularza. FastAPI czyta je z SYGNATURY, a wspólny handler nie może
   wymieniać `prompt_budget` ani `llm_provider`. Rozwiązane zależnością: moduł
   deklaruje własne pola u siebie, handler wie tylko, że dostaje słownik.
2. Klient warstwy logicznej jest wspólny, więc metody `prompt`/`horoscope`/
   `llm_models` musiały z niego wyjść. Wspólny klient daje teraz samą drogę
   w dół (`wywolaj`, `pobierz`, `strumien`) — z szyfrowaniem łącza i tokenem
   międzywarstwowym; co nią pojedzie, jest sprawą modułu.
3. Podtytuł ekranu Skompiluj miał wariant „z AI" i wariant bez. Zamiast warunku
   jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie.

DOCKERFILE. `COPY . .` wnosiło do obrazu także testy — a plik testowy nazywa
funkcje wprost. Teraz wchodzi wyłącznie `app/`.

TEST GRANICY. `test_ai_tylko_w_module.py` przechodzi po wszystkim poza modułem
i szuka siedemnastu słów. Znalazł dwanaście resztek, których nie widziałem:
komentarze w main.py i security.py, przykład `models.js` w komentarzu features.py,
komentarze w compile.js i w trzech arkuszach, oraz regułę `textarea.prompt`,
która przy rozbijaniu CSS trafiła do arkuszy ekranów zamiast do modułu.

Ma też kontrolę pozytywną (te słowa MAJĄ padać w module — inaczej test
przechodziłby, gdyby funkcję wydrążono) i test odłączalności mostu.

SPRAWDZONE NA KOPII BEZ MODUŁU: aplikacja startuje, wszystkie ekrany oddają 200,
katalog ma 13 funkcji zamiast 14, trasy strumienia nie ma, spreparowane
`action=prompt` wraca do akcji domyślnej bez śladu, a `grep` po drzewie nie
znajduje ani jednego ze słów.

Testy: presentation 362, logic 342, data 37, render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:25:56 +00:00
103 changed files with 3840 additions and 1383 deletions
+11
View File
@@ -32,3 +32,14 @@ jobs:
docker push $IMG:$TAG
docker push $IMG:latest
echo "Zbudowano i wypchnieto: $IMG:$TAG (+ latest)"
# Sprzątanie ZAWSZE, także po nieudanym budowaniu. Ten obraz dźwiga TeX Live
# i to właśnie na nim runner zatkał się kiedyś na „no space left on device" —
# a po nieudanym buildzie zostaje najwięcej śmieci.
- name: Sprzątanie po budowaniu
if: always()
run: |
docker system df || true
docker system prune -af --filter "until=168h" || true
docker builder prune -f --filter "until=168h" || true
docker system df || true
+96 -5
View File
@@ -1,21 +1,112 @@
name: build
# Buduje obrazy usług, które dzielą warstwę logiczną i łącze: data, logic,
# presentation, astrodemo. Render i silnik B mają własne pipeline'y, bo są ciężkie
# i rzadko się zmieniają (build-render.yaml, build-swisseph.yaml).
on:
push:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Dwa commity, bo porównujemy z poprzednim. Domyślny płytki klon ma
# jeden i `HEAD^` w nim nie istnieje.
fetch-depth: 2
- name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
# astrodemo dołącza do tej samej pętli: dzieli warstwę logiczną i łącze,
# więc jego obraz ma powstawać z tego samego commita co reszta produktu.
- name: Build & push (data, logic, presentation, astrodemo)
- name: Build & push (tylko usługi, które się zmieniły)
run: |
set -eu
TAG=${GITHUB_SHA::8}
for SVC in data logic presentation astrodemo; do
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
USLUGI="data logic presentation astrodemo astroklient"
# Usługi z własnym pipeline'em — celowo poza tą pętlą.
OSOBNE="render engine-swisseph"
# Usługa, której nie ma na żadnej z list, nigdy się nie zbuduje i NIKT
# tego nie zauważy: brak obrazu wygląda potem na problem z rejestrem.
# Ten sam mechanizm już nas kosztował przy liście obserwowanych obrazów
# w image-updaterze. Lepiej zatrzymać budowanie i powiedzieć wprost.
for KATALOG in services/*/; do
NAZWA=$(basename "$KATALOG")
case " $USLUGI $OSOBNE " in
*" $NAZWA "*) ;;
*) echo "BŁĄD: usługa '$NAZWA' nie jest na żadnej liście budowania."
echo "Dopisz ją do USLUGI w tym pliku albo daj jej własny workflow"
echo "(jak render i engine-swisseph), zależnie od tego, czy ma"
echo "powstawać z każdego commita produktu."
exit 1 ;;
esac
done
# Kontekstem budowania każdego obrazu jest WYŁĄCZNIE ./services/<nazwa>,
# więc zmiana poza tym katalogiem nie może wpłynąć na jego zawartość.
# Dzięki temu porównanie ścieżek jest dokładne, a nie przybliżone.
if POPRZEDNI=$(git rev-parse --verify HEAD^ 2>/dev/null); then
ZMIENIONE=$(git diff --name-only "$POPRZEDNI" HEAD)
else
# Pierwszy commit albo przepisana historia — nie ma z czym porównać,
# więc budujemy wszystko. Lepiej zbudować za dużo niż wypuścić obraz
# ze starym kodem pod nowym tagiem.
echo "Brak poprzedniego commita — buduję komplet."
ZMIENIONE=""
fi
# Zmiana w samym pliku workflow dotyczy wszystkich obrazów naraz.
if [ -z "$ZMIENIONE" ] || echo "$ZMIENIONE" | grep -q '^\.gitea/workflows/build\.yaml$'; then
DO_BUDOWY="$USLUGI"
else
DO_BUDOWY=""
for SVC in $USLUGI; do
if echo "$ZMIENIONE" | grep -q "^services/$SVC/"; then
DO_BUDOWY="$DO_BUDOWY $SVC"
fi
done
# astroklient bierze rdzeń z warstwy prezentacji, więc zmiana TAMTEJ
# też go dotyczy. Bez tego jego obraz zostawałby ze starymi ekranami,
# a różnicy nie byłoby widać aż do zgłoszenia użytkownika.
if echo "$ZMIENIONE" | grep -q "^services/presentation/" \
&& ! echo "$DO_BUDOWY" | grep -q "astroklient"; then
DO_BUDOWY="$DO_BUDOWY astroklient"
fi
fi
if [ -z "$(echo "$DO_BUDOWY" | tr -d ' ')" ]; then
echo "Żadna z usług się nie zmieniła — nie ma czego budować."
echo "Wdrożone tagi zostają na poprzednich wersjach, i tak ma być."
exit 0
fi
echo "Buduję:$DO_BUDOWY (tag $TAG)"
for SVC in $DO_BUDOWY; do
# astroklient buduje się z KORZENIA repozytorium, bo jego Dockerfile
# sięga po rdzeń do services/presentation. Pozostałe mają kontekst
# ograniczony do własnego katalogu — i tak ma zostać, bo to właśnie
# ten kontekst gwarantuje, że nie wciągną niczego spoza siebie.
if [ "$SVC" = "astroklient" ]; then
docker build -f services/astroklient/Dockerfile \
-t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG .
else
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
fi
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
done
echo "Tag: $TAG"
# Sprzątanie ZAWSZE, także po nieudanym budowaniu — to właśnie po awarii
# zostaje najwięcej śmieci, a kolejny przebieg zaczyna od mniejszego zapasu
# miejsca niż poprzedni. Tak zatkał się dysk przy budowaniu rendera.
- name: Sprzątanie po budowaniu
if: always()
run: |
docker system df || true
# `until=168h` zostawia tydzień: warstwy bazowe i cache z ostatnich dni
# przeżywają, więc kolejny build nie zaczyna od zera, a stare znikają.
docker system prune -af --filter "until=168h" || true
docker builder prune -f --filter "until=168h" || true
docker system df || true
+51
View File
@@ -112,6 +112,27 @@ jobs:
PYTHONPATH: .
run: pytest tests -q -rs
astroklient-tests:
name: Testy astroklient
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/astroklient/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/astroklient/requirements-dev.txt
# Testy budują drzewo tak samo, jak robi to Dockerfile: rdzeń z warstwy
# prezentacji, minus usun.txt, plus nakładka. Sprawdzają WYNIK złożenia,
# bo tylko on trafia do obrazu.
- name: Testy (pytest)
working-directory: services/astroklient
env:
PYTHONPATH: .
run: pytest tests -q -rs
swisseph-image:
name: Build obrazu silnika B (swisseph)
runs-on: ubuntu-latest
@@ -240,3 +261,33 @@ jobs:
# silnika swisseph, który nie wchodzi do produktu).
- name: py_compile
run: python -m compileall -q services
# Krok bez `run` i bez `uses` jest dla Gitei krokiem PUSTYM: zadanie mija
# go i kończy się powodzeniem, nie uruchomiwszy niczego. Tak właśnie
# zniknęły na jeden commit testy astrodemo — blok został rozcięty przy
# dopisywaniu sąsiedniego, a jego `run` przywędrował do cudzego zadania,
# które przez to przechodziło z niewłaściwego powodu.
#
# Zielone CI, które nic nie uruchomiło, jest gorsze od czerwonego.
- name: Kontrola kroków w workflow
run: |
python - <<'KONTROLA'
import pathlib, sys, yaml
grzechy = []
for f in sorted(pathlib.Path(".gitea/workflows").glob("*.y*ml")):
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
for nazwa, job in (d.get("jobs") or {}).items():
kroki = job.get("steps") or []
if not kroki:
grzechy.append(f"{f.name}: zadanie '{nazwa}' nie ma kroków")
for k in kroki:
if "run" not in k and "uses" not in k:
grzechy.append(
f"{f.name}: '{nazwa}' → krok "
f"'{k.get('name','(bez nazwy)')}' nie ma ani run, ani uses")
if grzechy:
print("PUSTE KROKI — zadanie przeszłoby nic nie robiąc:")
print("\n".join(" " + g for g in grzechy))
sys.exit(1)
print("Wszystkie kroki mają co uruchomić.")
KONTROLA
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.12-slim
WORKDIR /app
COPY services/astroklient/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Rdzeń wspólny z pełną aplikacją: ekrany astrologiczne, szablony, zasoby.
# 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.
COPY services/presentation/app ./app
# …minus wszystko, czego ten produkt nie ma. Lista jest DANYMI, a nie tekstem
# w tym pliku: czyta ją także test, który pilnuje, żeby zgadzała się ze
# złożeniem w main.py. Rozjazd między nimi znaczyłby albo martwy kod w obrazie,
# albo brakujący plik i błąd dopiero przy uruchomieniu.
COPY services/astroklient/usun.txt .
RUN while read -r SCIEZKA; do \
[ -n "$SCIEZKA" ] || continue; \
test -e "app/$SCIEZKA" || { echo "usun.txt wskazuje nieistniejące: $SCIEZKA"; exit 1; }; \
rm -rf "app/$SCIEZKA"; \
done < usun.txt && rm usun.txt
# Nakładka: własne logowanie, własna pula plików, własny ekran wgrywania,
# własne złożenie. Nadpisuje pliki o tych samych nazwach.
COPY services/astroklient/app ./app
EXPOSE 8006
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8006"]
@@ -0,0 +1,174 @@
"""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))
@@ -0,0 +1,75 @@
"""Wgrywanie plików: jedna czynność, nie dwie.
„Dodać plik" ma tu znaczyć, że od razu bierze udział w wyszukiwaniu — inaczej po
wgraniu nic by się nie zmieniło i wyglądałoby to na zepsute. Nie ma listy plików
ani ich stanów: ta usługa ma jeden poziom dostępu, więc nie ma komu ich
pokazywać ani po co.
"""
from __future__ import annotations
import base64
import httpx
from fastapi import File, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from app.features import Feature
from app.podstawa import app, features, logic, templates, _logic_error
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
# tutaj wprost, bo ta usługa tamtej warstwy nie importuje; literówka objawiłaby
# się dopiero na żywo, więc pilnuje jej test.
W_UZYCIU = "active"
@app.get("/pliki", response_class=HTMLResponse)
def wgrywanie_form(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "wgrywanie.html",
{"error": error, "done": done})
@app.post("/pliki/wgraj")
async def wgraj(request: Request, upload: UploadFile = File(...)):
"""Wgranie ORAZ włączenie do użytku.
Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje
zapisany i czeka. Komunikat nie mówi, CZYM się nie nadaje: różnica w treści
byłaby wyrocznią, z której da się odczytać, czego program oczekuje.
"""
raw = await upload.read()
if not raw:
return _wroc(error="Pusty plik.")
kto = getattr(request.state, "user", "") or "?"
try:
out = logic.files_upload(upload.filename or "dane.xlsx",
base64.b64encode(raw).decode("ascii"), by=kto)
except httpx.HTTPError as e:
return _wroc(error=_logic_error(e))
nazwa = out.get("name") or "plik"
if not out.get("accepted"):
return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się "
f"do użycia w tej postaci.")
try:
logic.files_status(out["path"], W_UZYCIU, by=kto)
except httpx.HTTPError as e:
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć.")
return _wroc(done=f"Dodano „{nazwa}”. Plik jest już używany przy wyszukiwaniu.")
def _wroc(error: str = "", done: str = "") -> RedirectResponse:
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
wgrać pliku drugi raz."""
from urllib.parse import urlencode
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
return RedirectResponse(f"/pliki{'?' + q if q else ''}", status_code=303)
features.zarejestruj(
ekran=Feature("files", "Pliki", "Dodawanie plików używanych przy wyszukiwaniu.",
"Ekrany", "/pliki", kolejnosc=70),
trasy={("GET", "/pliki"): "files", ("POST", "/pliki/wgraj"): "files"},
zasoby={"s-wgrywanie.css": "files"},
)
+34
View File
@@ -0,0 +1,34 @@
"""Astroklient — ZŁOŻENIE produktu.
Pełne funkcje astrologiczne, bez generowania tekstu i bez administracji. Ten plik
nie zawiera żadnej trasy: odpowiada wyłącznie na pytanie, z czego składa się ten
produkt. Import ekranu rejestruje jego trasy, więc lista poniżej JEST definicją.
Poza tą listą nie ma nic. Nie chodzi o funkcje wyłączone: plików, których tu nie
widać, nie ma w obrazie (patrz usun.txt), więc nie istnieje uprawnienie, którym
dałoby się cokolwiek odsłonić — ani nazwa, po której dałoby się zgadnąć, czego
szukać.
"""
from __future__ import annotations
from app import rozszerzenia
from app.podstawa import (_asset_body, _asset_version, _build_utc, _logic_error, app,
logic, static_url, templates)
from app.ekrany import ( # noqa: F401 - import rejestruje trasy
horoskop,
interpretacje,
kalendarz,
logowanie,
lokalizacja,
sygnifikatory,
synastria,
wgrywanie,
)
__all__ = ["app", "logic", "templates", "static_url", "_asset_body", "_asset_version"]
# Most modułów opcjonalnych zostaje CELOWO. W tym obrazie nie ma katalogu
# `app/moduly/`, więc odkrywa zero modułów i wszystkie odpowiedzi są puste —
# aplikacja zachowuje się tak, jakby takiego pojęcia nigdy nie było.
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
buduj_utc=_build_utc, blad=_logic_error)
+8
View File
@@ -0,0 +1,8 @@
"""Nazwa produktu — nakładka astroklienta.
Nadpisuje wersję z rdzenia. Test słownikowy pilnuje, żeby po nadpisaniu nazwa
tamtego produktu nie została nigdzie w szablonach, zasobach ani w odpowiedziach.
"""
from __future__ import annotations
NAZWA = "astroklient"
+384
View File
@@ -0,0 +1,384 @@
"""Logowanie i bramka uprawnień astroklienta.
Konta pochodzą WYŁĄCZNIE z konfiguracji środowiska (`ASTROKLIENT_USERS`), a każde
dostaje ten sam zestaw: wszystko, co ta usługa umie. Konta rozdziela się po to,
żeby każde miało własną, niewidoczną dla pozostałych pulę plików — nie po to, żeby
dawać im różne poziomy dostępu. Poziom jest jeden i nie ma go z czym porównywać.
Reszta jest wspólna z pozostałymi usługami i celowo nietknięta: hasła jako scrypt,
sesja w podpisanym ciasteczku, limit żądań, dziennik audytowy, oraz odmowa jako
404 zamiast 403 — bo 403 samo w sobie mówi, że coś tam jest.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import logging
import os
import secrets
import time
from collections import deque
from dataclasses import dataclass
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
log = logging.getLogger("astrololo.security")
MAX_TRACKED_CLIENTS_DEFAULT = 4096
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
# ── konta ─────────────────────────────────────────────────────────────────
# Wyłącznie z konfiguracji środowiska. Konta rozdziela się po to, żeby każde
# miało własną pulę plików — nie po to, żeby różniły się tym, co widzą.
# ── konta imienne (PRE-17) ───────────────────────────────────────────────
# Parametry scrypt: rozsądny kompromis czas/pamięć dla logowania do aplikacji
# wewnętrznej. Zmiana wymaga przeliczenia haseł (hash niesie tylko sól).
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
_HASH_PREFIX = "scrypt$"
def hash_password(password: str, salt: bytes | None = None) -> str:
"""Hasło → wpis do ASTROKLIENT_USERS: `scrypt$<sól_hex>$<hash_hex>`."""
salt = salt or secrets.token_bytes(16)
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
return f"{_HASH_PREFIX}{salt.hex()}${dk.hex()}"
def accounts() -> dict[str, str]:
"""Konta z `ASTROKLIENT_USERS` — `login:sekret`, po przecinku.
Sekret to hash scrypt (`scrypt$sól$hash`), ten sam format co w pozostałych
usługach — jedna implementacja, więc nie ma czego rozjechać."""
out: dict[str, str] = {}
for entry in os.getenv("ASTROKLIENT_USERS", "").split(","):
entry = entry.strip()
if not entry:
continue
user, sep, secret = entry.partition(":")
if sep and user.strip() and secret.strip():
out[user.strip()] = secret.strip()
return out
def _secret_matches(spec: str, password: str) -> bool:
"""Sekret konta vs podane hasło. Hash scrypt albo (zgodność wstecz) jawne."""
if spec.startswith(_HASH_PREFIX):
try:
_, salt_hex, hash_hex = spec.split("$", 2)
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
except ValueError:
return False
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
return hmac.compare_digest(actual, expected)
# Porównujemy BAJTY, nie tekst: `compare_digest` na stringach rzuca TypeError
# przy znakach spoza ASCII, więc hasło z polskimi literami wywracało logowanie
# błędem 500 zamiast zwykłej odmowy (błąd zastany, złapany testem).
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
def rate_limit_per_min() -> int:
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
def cookies_secure() -> bool:
"""Czy ciasteczko sesji ma być wyłącznie dla https.
Domyślnie TAK. Wyłączamy jedynie w pracy lokalnej po http — inaczej
przeglądarka po prostu nie zapamięta ciasteczka i logowanie „nie działa"
bez żadnego komunikatu, co jest wyjątkowo mylącą awarią."""
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
def trust_proxy() -> bool:
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
# /wyloguj jest „publiczna" tylko technicznie: przechodzi obok bramki, żeby móc
# odpowiedzieć 401 CELOWO, zamiast dostać 200 od zalogowanej sesji. Bez tego
# wylogowanie byłoby niemożliwe — przeglądarka dosyła zapamiętane dane przy każdym
# żądaniu, więc handler i tak widziałby zalogowaną osobę.
# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być
# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła.
# Ekran logowania musi się wyświetlić PRZED zalogowaniem, więc jego arkusz jest
# jedynym publicznym zasobem — i dlatego nie wolno w nim trzymać niczego, co
# nazywa funkcję (patrz features.STATIC).
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/base.css"})
# Pusto CELOWO. Dopóki był tu "/static/", cały katalog omijał bramkę: każdy plik
# JS i arkusz stylów pobierał ktokolwiek, także niezalogowany, pod zgadywalnym
# adresem — a ich treść wymienia ekrany, dostawców modeli i przeznaczenie baz.
# Ruch do zasobów nie trafiał przy tym ani do dziennika, ani pod limit żądań,
# więc wyciek był dla właściciela niewidoczny.
PUBLIC_PREFIXES: tuple[str, ...] = ()
_hits: dict[str, deque[float]] = {}
def auth_enabled() -> bool:
return bool(accounts())
def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
@dataclass(frozen=True)
class Principal:
"""Kto się zalogował i co wolno mu zobaczyć."""
login: str
permissions: frozenset[str]
is_admin: bool
def _uprawnienia() -> frozenset[str]:
"""Każde konto dostaje wszystko, co ta usługa umie — liczone z KATALOGU.
Katalog składa się ze zgłoszeń ekranów obecnych w obrazie, więc ten zbiór
opisuje dokładnie ten produkt, a nie listę wpisaną kiedyś ręcznie. Dopisanie
ekranu nie wymaga pamiętania o niczym; usunięcie go zabiera uprawnienie samo.
Nie ma tu uprawnienia administracyjnego i nie ma go skąd wziąć: `features.ADMIN`
celowo nie należy do GRANTABLE."""
from app import features
return frozenset(features.GRANTABLE)
def _sekret(login: str) -> str | None:
return accounts().get(login)
def verify(login: str, password: str) -> Principal | None:
"""Sprawdza login i hasło. None = odmowa, bez podawania powodu.
Nieznany login i złe hasło dają ten sam wynik: rozróżnienie mówiłoby, które
konta istnieją, a to informacja warta czegoś dla zgadującego."""
spec = _sekret(login)
if spec is None or not _secret_matches(spec, password):
return None
return Principal(login, _uprawnienia(), False)
def issue_session(login: str) -> str:
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
W ciasteczku ląduje ODCISK poświadczenia, dzięki czemu zmiana hasła albo
skasowanie konta unieważnia jego sesje same z siebie — bez pamiętania o tym
i bez żadnego magazynu sesji."""
from app import session
# Wersja sesji zawsze 0: nie ma trwałego magazynu, więc nie ma gdzie trzymać
# licznika pokolenia. Wylogowanie i zmiana hasła i tak działają — pierwsze
# przez skasowanie ciasteczka, drugie przez odcisk, który przestaje pasować.
return session.issue(login, session.fingerprint(_sekret(login) or ""), 0)
def principal(request: Request) -> Principal | None:
"""Ciasteczko → kto to jest. None = nie wpuszczać."""
from app import session
dane = session.read(request.cookies.get(session.COOKIE))
if dane is None:
return None
spec = _sekret(dane["login"])
if spec is None:
return None
# Zmiana hasła w konfiguracji ubija sesje tego konta — to jest tutaj jedyny
# sposób na „wyloguj mnie ze wszystkich urządzeń", bo bez trwałego magazynu
# nie ma gdzie trzymać licznika pokolenia sesji.
if session.fingerprint(spec) != dane["odcisk"]:
return None
return Principal(dane["login"], _uprawnienia(), False)
def client_ip(request: Request) -> str:
"""Adres, po którym rozliczamy limit żądań.
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
wyklika limit, odcięłaby pozostałe.
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
klient, więc nie znaczą nic.
"""
peer = request.client.host if request.client else "?"
if not trust_proxy():
return peer
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
last = forwarded.rsplit(",", 1)[-1].strip()
if last:
return last
return request.headers.get("x-real-ip", "").strip() or peer
def _rate_limited(client: str) -> bool:
cap = rate_limit_per_min()
if cap <= 0:
return False
now = time.monotonic()
window = _hits.get(client)
if window is None:
if len(_hits) >= MAX_TRACKED_CLIENTS:
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
window = _hits[client] = deque()
while window and now - window[0] > 60.0:
window.popleft()
if len(window) >= cap:
return True
window.append(now)
return False
# ── dziennik audytowy (PRE-17) ───────────────────────────────────────────
audit_log = logging.getLogger("astrololo.audit")
def _setup_audit_logging() -> None:
"""Gwarantuje, że wpisy audytowe FAKTYCZNIE wychodzą na stdout.
Domyślna konfiguracja uvicorna nie obsługuje naszych loggerów, więc wpisy na
poziomie INFO ginęły — dziennik istniał w kodzie, ale był pusty (sprawdzone
na żywym serwerze). Niewidoczny dziennik jest gorszy niż jego brak: daje
złudzenie kontroli. Własny handler na stdout, bo w k8s to stamtąd zbierane
są logi. `propagate=False` — żeby wpis nie dublował się przez korzeń.
"""
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
if not audit_log.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
audit_log.addHandler(handler)
audit_log.propagate = False
def audit_records(request: Request, count: int) -> None:
"""Handler zgłasza, ILE rekordów baz oddał — trafi do wpisu audytowego.
Sama liczba, nigdy treść. Bez tego dziennik pokazywałby tylko „ktoś wchodził",
a nie „ktoś wyniósł 5000 rekordów" — a to drugie jest sygnałem nadużycia."""
try:
request.state.audit_records = int(count)
except (TypeError, ValueError):
pass
def audit_login(request: Request, login: str, udane: bool) -> None:
"""Zdarzenie logowania w dzienniku (PRE-17).
Nieudane próby są tu ważniejsze od udanych: pojedyncza nic nie znaczy, ale
seria pod jednym adresem to jedyny widoczny ślad zgadywania haseł. Logujemy
LOGIN, nigdy hasła ani jego fragmentu — dziennik to kolejny nośnik wycieku."""
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
"logowanie-ok" if udane else "logowanie-odmowa",
login or "-", client_ip(request))
def audit_logout(request: Request) -> None:
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
getattr(request.state, "user", "") or "-", client_ip(request))
def _audit(request: Request, user: str, status: int, ms: float) -> None:
records = getattr(request.state, "audit_records", None)
audit_log.info(
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
user or "-", client_ip(request), request.method, request.url.path,
status, "-" if records is None else records, ms,
)
def install(app) -> None:
"""Podpina ochronę pod wszystkie ścieżki poza publicznymi."""
from app import session
_setup_audit_logging()
# FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie umiałaby odróżnić
# ważnej sesji od podrobionej. Lepiej, żeby nie wstała, niż żeby wpuszczała —
# i lepiej teraz, przy starcie, niż przy pierwszym logowaniu człowieka.
if auth_enabled() and not session.available():
raise session.SessionError(
"Konta są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
"podpisu sesje nie działają, a logowanie nie miałoby czym się bronić. "
"Wygeneruj: openssl rand -hex 32")
if not auth_enabled():
log.warning(
"UWAGA: brak kont (ASTROKLIENT_USERS) — usługa jest OTWARTA dla "
"każdego, kto ma do niej dostęp sieciowy, wraz z treścią plików."
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if _is_public(request.url.path):
return await call_next(request)
started = time.monotonic()
if _rate_limited(client_ip(request)):
_audit(request, "-", 429, (time.monotonic() - started) * 1000)
return JSONResponse(
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
status_code=429, headers={"Retry-After": "60"},
)
from app import features
user, who, odswiez = "", None, None
if auth_enabled():
who = principal(request)
if who is None:
_audit(request, "-", 303, (time.monotonic() - started) * 1000)
# Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało
# okienko systemowe, którego nie da się ani opisać, ani z niego wyjść.
# Zapamiętujemy, dokąd ktoś zmierzał — po zalogowaniu wraca na miejsce.
from urllib.parse import quote
cel = request.url.path
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
user = who.login
# Ruch przedłuża sesję, ale ciasteczko odsyłamy tylko wtedy, gdy
# znacznik zdążył się zestarzeć — inaczej Set-Cookie leciałby przy
# każdym obrazku i arkuszu stylów.
from app import session as _s
dane = _s.read(request.cookies.get(_s.COOKIE))
odswiez = _s.refresh(dane) if dane else None
else:
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
who = Principal("", _uprawnienia(), False)
request.state.user = user
request.state.principal = who
# Pula plików na czas obsługi tego żądania. Ustawiana TU, w jednym
# miejscu, zaraz po ustaleniu kto to jest — klient czyta ją z kontekstu,
# więc żadne wywołanie w dół nie może o niej zapomnieć.
from app.clients.logic_client import KONTO
KONTO.set(user)
# 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest —
# a konto ograniczone ma widzieć program kompletny, tylko mniejszy.
need = features.required(request.method, request.url.path)
if not features.can(who.permissions, need):
_audit(request, user, 404, (time.monotonic() - started) * 1000)
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
response = await call_next(request)
if odswiez:
from app import session as _s
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
**_s.cookie_params(secure=cookies_secure()))
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
return response
+186
View File
@@ -0,0 +1,186 @@
"""Sesje logowania — podpisane ciasteczko (LOG-34).
CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest
w środku, i może tę decyzję cofnąć.
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu,
działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj".
2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować.
Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania
o tym — i też nie wymaga magazynu.
3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji,
który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja
nie poda licznika, poziomy 1 i 2 nadal działają.
Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę
hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie
dostępu nie odbierałoby dostępu.
KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie
byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie
poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania.
Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt
w PRE-27 i AES w PRE-16.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały
# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby
# z jednej, logując się do drugiej.
COOKIE = "astroklient_sesja"
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec
# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde
# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów.
REFRESH_AFTER = 300
class SessionError(RuntimeError):
"""Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia."""
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, "") or default)
except ValueError:
return default
def max_age() -> int:
return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE)
def idle_timeout() -> int:
return _env_int("SESSION_IDLE", DEFAULT_IDLE)
def secret() -> bytes:
"""Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz.
Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji
od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała."""
raw = os.getenv("SESSION_SECRET", "").strip()
if not raw:
raise SessionError(
"Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. "
"Wygeneruj: openssl rand -hex 32")
if len(raw) < 32:
raise SessionError(
f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; "
f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.")
return raw.encode("utf-8")
def available() -> bool:
"""Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie)."""
try:
secret()
except SessionError:
return False
return True
def fingerprint(secret_spec: str) -> str:
"""Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza).
NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót
zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt.
Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta
unieważniły jego sesje same z siebie."""
return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16]
def _sign(payload: bytes) -> bytes:
return hmac.new(secret(), payload, hashlib.sha256).digest()
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _unb64(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str:
"""Nowe ciasteczko sesji dla konta."""
teraz = int(now if now is not None else time.time())
payload = json.dumps(
{"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz},
separators=(",", ":"), sort_keys=True).encode("utf-8")
return f"{_b64(payload)}.{_b64(_sign(payload))}"
def read(token: str | None, *, now: float | None = None) -> dict | None:
"""Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza.
None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości
dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga
zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania."""
if not token or "." not in token:
return None
surowy, _, podpis = token.rpartition(".")
try:
payload = _unb64(surowy)
oczekiwany = _sign(payload)
podany = _unb64(podpis)
except (ValueError, SessionError):
return None
# Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile
# pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia.
if not hmac.compare_digest(oczekiwany, podany):
return None
try:
dane = json.loads(payload.decode("utf-8"))
login, odcisk = str(dane["u"]), str(dane["f"])
wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"])
except (ValueError, KeyError, TypeError):
return None
teraz = int(now if now is not None else time.time())
# Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie —
# w obu wypadkach nie ma powodu mu ufać.
if wyd > teraz + 60:
return None
if teraz - wyd > max_age() or teraz - akt > idle_timeout():
return None
return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt}
def refresh(dane: dict, *, now: float | None = None) -> str | None:
"""Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć.
None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby
Set-Cookie — także po arkusze stylów i obrazki."""
teraz = int(now if now is not None else time.time())
if teraz - dane["aktywnosc"] < REFRESH_AFTER:
return None
payload = json.dumps(
{"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"],
"wyd": dane["wydano"], "akt": teraz},
separators=(",", ":"), sort_keys=True).encode("utf-8")
return f"{_b64(payload)}.{_b64(_sign(payload))}"
def cookie_params(secure: bool = True) -> dict:
"""Parametry ciasteczka wspólne dla ustawiania i kasowania.
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
na tym, że obca strona wysyła żądanie w imieniu zalogowanego."""
return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"}
@@ -0,0 +1,4 @@
/* Arkusz ekranu „files” — dokładany tylko dla konta z tym uprawnieniem. */
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 1rem 1.1rem; margin-top: .4rem; }
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% set nav_active = "files" %}
{% block title %}Pliki{% endblock %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-wgrywanie.css') }}">
{% endblock %}
{% block content %}
{# Opis czysto operacyjny: co robi wgranie, i nic o tym, do CZEGO te dane służą
ani co się z nimi dzieje dalej. #}
<p class="muted">Wgrany plik jest od razu używany przy wyszukiwaniu.</p>
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Wgraj plik</div>
<form method="post" action="/pliki/wgraj" enctype="multipart/form-data" class="card">
<div class="grid">
<label>Plik <input type="file" name="upload" required></label>
</div>
<div class="actions"><button type="submit">Wgraj</button></div>
</form>
{% endblock %}
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0
+14
View File
@@ -0,0 +1,14 @@
fastapi>=0.115
uvicorn[standard]>=0.34
httpx>=0.28
jinja2>=3.1
python-multipart>=0.0.20
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
# Strefa czasowa z lokalizacji (PRE-03): współrzędne → strefa IANA (offline, lekki
# wheel Rust), a offset/DST liczy stdlib zoneinfo. tzdata na wypadek slim-obrazu
# bez systemowej bazy stref.
tzfpy>=0.15
tzdata>=2024.1
# Eksport wyników do Excela — „tabela robocza" (DAN-23/PRE-10)
openpyxl>=3.1
+94
View File
@@ -0,0 +1,94 @@
"""Drzewo astroklienta budowane tak samo, jak buduje je Dockerfile.
Ta usługa nie ma własnego kompletu plików: bierze rdzeń z warstwy prezentacji,
usuwa z niego to, czego ten produkt nie ma (usun.txt), i nakłada własne. Testy
muszą patrzeć na WYNIK tego złożenia, a nie na katalog `app/` w repozytorium —
inaczej sprawdzałyby garść plików, a nie produkt.
"""
from __future__ import annotations
import json
import pathlib
import re
import shutil
import subprocess
import sys
import pytest
USLUGA = pathlib.Path(__file__).resolve().parents[1]
RDZEN = USLUGA.parents[0] / "presentation" / "app"
def zloz(cel: pathlib.Path) -> pathlib.Path:
"""Rdzeń minus usun.txt plus nakładka — dokładnie jak w Dockerfile."""
app = cel / "app"
shutil.copytree(RDZEN, app, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
for linia in (USLUGA / "usun.txt").read_text(encoding="utf-8").splitlines():
sciezka = linia.strip()
if not sciezka:
continue
do_usuniecia = app / sciezka
assert do_usuniecia.exists(), f"usun.txt wskazuje nieistniejące: {sciezka}"
shutil.rmtree(do_usuniecia) if do_usuniecia.is_dir() else do_usuniecia.unlink()
shutil.copytree(USLUGA / "app", app, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
return cel
@pytest.fixture(scope="session")
def drzewo(tmp_path_factory) -> pathlib.Path:
return zloz(tmp_path_factory.mktemp("astroklient"))
SONDA = '''
import json, os, hashlib, secrets, re
sol = secrets.token_bytes(16)
dk = hashlib.scrypt(b"tajne", salt=sol, n=2**14, r=8, p=1, dklen=32)
os.environ.update(ASTROKLIENT_USERS="klient:scrypt$%s$%s" % (sol.hex(), dk.hex()),
SESSION_SECRET="t"*64, COOKIES_INSECURE="1", RATE_LIMIT_PER_MIN="0")
from starlette.testclient import TestClient
from app.main import app
from app import security, session, features
from app.clients import logic_client
c = TestClient(app, follow_redirects=False)
H = {"Cookie": f"{session.COOKIE}={security.issue_session('klient')}"}
strona = c.get("/", headers=H).text
zebrane = []
logic_client.link_crypto.call_json = lambda *a, **kw: (zebrane.append(kw.get("payload")), {})[1]
c.post("/interpret", headers=H, data={"date":"1984-04-30","time":"11:20",
"tz_offset":"2","lat":"50","lon":"19","tenant":"ktos-inny"})
print("WYNIK:" + json.dumps({
"ekrany": [f.label for f in features.SCREENS],
"uprawnienia": sorted(security._uprawnienia()),
"dziala": {a: c.get(a, headers=H).status_code
for a in ("/", "/interpret", "/timeline", "/synastry",
"/significators", "/pliki")},
"nie_ma": {a: c.get(a, headers=H).status_code
for a in ("/compile", "/settings", "/accounts", "/files",
"/horoscope/stream", "/compile/pdf")},
"nawigacja": re.findall(r"<nav>(.*?)</nav>", strona, re.S)[0].count('<a href="'),
"pula": (zebrane[0] or {}).get("tenant"),
"nazwa_w_naglowku": (re.search(r"<h1>(.*?)</h1>", strona) or [None, "?"])[1],
"nazwa_w_tytule": (re.search(r"<title>(.*?) ·", strona) or [None, "?"])[1],
"rdzen_w_tresci": "astrololo" in strona.lower(),
"dokumentacja": {a: c.get(a, headers=H).status_code
for a in ("/docs", "/openapi.json", "/redoc")},
"logowanie_dziala": bool(security.verify("klient", "tajne")),
"zle_haslo_odmowa": security.verify("klient", "inne") is None,
}))
'''
@pytest.fixture(scope="module")
def wynik(drzewo):
"""Aplikacja uruchomiona w OSOBNYM PROCESIE.
Importy Pythona są zapamiętywane w procesie, więc sprawdzanie „produktu bez
modułu" tam, gdzie moduł już się zaimportował, dałoby wynik fałszywie
pozytywny."""
r = subprocess.run([sys.executable, "-c", SONDA], cwd=drzewo, capture_output=True,
text=True, env={"PYTHONPATH": str(drzewo), "PATH": "/usr/bin:/bin"})
linia = next((l for l in r.stdout.splitlines() if l.startswith("WYNIK:")), None)
assert linia, f"sonda nie wystartowała:\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}"
return json.loads(linia[len("WYNIK:"):])
@@ -0,0 +1,137 @@
"""Po czym astroklienta NIE MA — ani w odpowiedzi, ani w kodzie w obrazie.
Ta usługa dzieli kod z pełną aplikacją, więc granica między produktami nie jest
oczywista z samego patrzenia: przebiega przez listę importów w `main.py` i przez
`usun.txt`. Ten test sprawdza jej SKUTEK na złożonym drzewie — bo tylko skutek
trafia do obrazu, który komuś się oddaje.
„Bez śladu" znaczy tu również bez zaprzeczeń. Zdanie „nie ma tu generowania
tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak
przycisk. Pierwsza wersja tego testu wyłapała trzy moje własne docstringi
wyliczające nieobecne funkcje.
"""
from __future__ import annotations
import pathlib
import re
import pytest
# Funkcje, których ten produkt nie ma — ani ich słownictwo.
GENEROWANIE = ["prompt", "LLM", "model językow", "generowanie tekstu", "sztuczn",
"anthropic", "openai", "gpt", "claude", "horoscope",
"x-ai.css", "models.js", "progress.js", "natal.js", "predictions.js"]
NIEOBECNE_EKRANY = ["Skompiluj", "Ustawienia", "Konta i uprawnien", "Eksport plików"]
MECHANIZMY = ["administrator", "kwarantann", "walidacj", "reguły przyjmowania"]
SLOWA = GENEROWANIE + NIEOBECNE_EKRANY + MECHANIZMY
WZORCE = [(r"(?<![\w-])AI(?![\w-])", "skrót nazwy funkcji")]
def _pliki(drzewo: pathlib.Path):
for f in sorted((drzewo / "app").rglob("*")):
if f.is_file() and f.suffix != ".pyc" and "__pycache__" not in str(f):
yield f
def test_the_shipped_tree_carries_no_trace(drzewo):
grzechy = []
for f in _pliki(drzewo):
try:
tresc = f.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
gdzie = f.relative_to(drzewo)
for slowo in SLOWA:
if re.search(rf"(?i)(?<![\w-]){re.escape(slowo)}", tresc):
grzechy.append(f"{gdzie}: „{slowo}")
for wzor, powod in WZORCE:
m = re.search(wzor, tresc)
if m:
grzechy.append(f"{gdzie}: „{m.group(0)}” — {powod}")
assert not grzechy, (
"\nW drzewie astroklienta padły słowa o funkcjach, których nie ma. "
"`grep` po kontenerze ma nic nie znaleźć:\n " + "\n ".join(grzechy))
def test_the_check_would_notice_if_something_slipped_in(drzewo, tmp_path):
"""Kontrola negatywna: zapora ma coś łapać.
Bez niej test wyżej przechodziłby także wtedy, gdyby lista słów była pusta
albo gdyby przeszukiwanie trafiało w niewłaściwy katalog."""
podrzucony = drzewo / "app" / "podrzucony.py"
podrzucony.write_text("# generowanie tekstu przez model\n", encoding="utf-8")
try:
with pytest.raises(AssertionError):
test_the_shipped_tree_carries_no_trace(drzewo)
finally:
podrzucony.unlink()
def test_the_removal_list_matches_what_is_actually_composed(drzewo):
"""usun.txt i main.py muszą mówić to samo.
Rozjazd między nimi znaczy albo martwy kod w obrazie (moduł usunięty
z importów, ale nie z listy), albo błąd dopiero przy uruchomieniu (plik
usunięty, a wciąż importowany). Jedno i drugie wychodzi za późno.
"""
main = (drzewo / "app" / "main.py").read_text(encoding="utf-8")
zaimportowane = set(re.findall(r"^\s{4}(\w+),$", main, re.M))
obecne = {f.stem for f in (drzewo / "app" / "ekrany").glob("*.py")
if f.stem != "__init__"}
assert zaimportowane == obecne, (
f"ekrany w drzewie: {sorted(obecne)}\n"
f"ekrany importowane: {sorted(zaimportowane)}")
def test_no_optional_module_survived(drzewo):
"""Katalogu modułów opcjonalnych nie ma wcale — most odkrywa wtedy zero."""
assert not (drzewo / "app" / "moduly").exists()
# ── nazwa produktu, z którego ten produkt powstał ─────────────────────────
# Osobno od listy wyżej, bo granica przebiega gdzie indziej: „astrololo" wolno
# występować w identyfikatorach WEWNĘTRZNYCH (nazwy loggerów, materiał do
# wyprowadzania kluczy, nagłówki między warstwami), a nie wolno w niczym, co
# dociera do przeglądarki. Zakaz na wszystko byłby albo obejściem przez zmianę
# nazw kluczy kryptograficznych, albo martwą regułą wyłączaną przy pierwszej
# okazji.
NAZWA_RDZENIA = "astrololo"
WIDOCZNE_DLA_KLIENTA = ("templates", "static")
def test_the_parent_product_is_not_named_in_anything_the_browser_receives(drzewo):
"""Szablony i zasoby są WSPÓLNE z pełną aplikacją, więc jej nazwa jechała
z nimi do tego obrazu — i astroklient przedstawiał się jako astrololo.
To jest najpoważniejszy z możliwych śladów: mówi klientowi wprost, że trzyma
okrojoną wersję czegoś większego, i podaje frazę, po której da się wyszukać,
czego mu nie dano."""
grzechy = []
for katalog in WIDOCZNE_DLA_KLIENTA:
for f in sorted((drzewo / "app" / katalog).rglob("*")):
if not f.is_file() or "__pycache__" in str(f):
continue
try:
tresc = f.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
if NAZWA_RDZENIA in tresc.lower():
grzechy.append(str(f.relative_to(drzewo)))
assert not grzechy, (
f"\nNazwa „{NAZWA_RDZENIA}” pada w plikach, które dostaje "
f"przeglądarka:\n " + "\n ".join(grzechy))
def test_the_rendered_pages_carry_this_products_own_name(wynik):
"""Sprawdzane na ODPOWIEDZI, nie na szablonie: liczy się to, co dociera do
człowieka, a nie to, co jest w pliku przed złożeniem."""
assert wynik["nazwa_w_naglowku"] == "astroklient"
assert wynik["nazwa_w_tytule"] == "astroklient"
assert not wynik["rdzen_w_tresci"], (
"nazwa pełnej aplikacji pada w treści strony oddawanej klientowi")
def test_the_api_documentation_is_not_reachable(wynik):
"""Tytuł aplikacji FastAPI wciąż niesie nazwę rdzenia, więc dokumentacja
OpenAPI byłaby wyciekiem tylnymi drzwiami — nawet gdy szablony są czyste."""
assert all(k == 404 for k in wynik["dokumentacja"].values()), wynik["dokumentacja"]
@@ -0,0 +1,46 @@
"""Astroklient jest produktem, a nie pełną aplikacją z wyłączonymi kawałkami.
Sprawdzamy trzy rzeczy, i wszystkie na ZŁOŻONYM drzewie, nie na kodzie w repo:
czego w nim nie ma, że to, co zostało, działa, i że nie zostało po niczym słowo.
"""
from __future__ import annotations
def test_the_build_starts_at_all(wynik):
assert wynik["dziala"]["/"] == 200
def test_every_astrological_screen_works(wynik):
assert all(k == 200 for k in wynik["dziala"].values()), wynik["dziala"]
def test_what_it_lacks_does_not_exist_rather_than_being_refused(wynik):
"""404, nie 403 i nie 500. Odmowa z powodem jest informacją, że coś tam jest."""
assert all(k == 404 for k in wynik["nie_ma"].values()), wynik["nie_ma"]
def test_the_catalogue_describes_this_product(wynik):
assert set(wynik["ekrany"]) == {"Horoskop", "Interpretacje", "Kalendarz",
"Synastria", "Sygnifikatory", "Pliki"}
def test_no_account_can_hold_a_permission_this_product_does_not_have(wynik):
"""Uprawnienia liczą się z KATALOGU, więc opisują ten obraz. Gdyby była tu
lista wpisana ręcznie, zostałaby po usuniętych funkcjach."""
assert "ai" not in wynik["uprawnienia"]
assert "export" not in wynik["uprawnienia"]
assert "admin" not in wynik["uprawnienia"]
assert "chart" in wynik["uprawnienia"] and "significators" in wynik["uprawnienia"]
def test_navigation_has_no_dead_links(wynik):
assert wynik["nawigacja"] == len(wynik["ekrany"])
def test_login_still_tells_right_password_from_wrong(wynik):
assert wynik["logowanie_dziala"] and wynik["zle_haslo_odmowa"]
def test_the_pool_comes_from_the_session_not_from_the_form(wynik):
"""Pula jedzie z zalogowanego konta. Gdyby brała się z żądania, wystarczyłoby
podstawić cudzy login, żeby czytać cudze pliki — sonda właśnie to próbuje."""
assert wynik["pula"] == "klient"
+16
View File
@@ -0,0 +1,16 @@
moduly
ekrany/skompiluj.py
ekrany/ustawienia.py
ekrany/konta.py
ekrany/pliki.py
accounts.py
files_state.py
templates/compile.html
templates/settings.html
templates/accounts.html
templates/files.html
static/s-compile.css
static/s-settings.css
static/s-accounts.css
static/s-files.css
static/compile.js
+4
View File
@@ -599,6 +599,10 @@ class TimelineRequest(BaseModel):
to_date: str
techniques: list[str] | None = None # profection | solar_return | solar_arc
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
# Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie
# rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa
# prezentacji, bo to ona zna konta.
tenant: str = ""
@app.post("/chart/timeline")
+4
View File
@@ -8,6 +8,10 @@ from pydantic import BaseModel, Field
class QueryRequest(BaseModel):
"""To, co przychodzi z formularza (przez warstwę prezentacji)."""
# Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie
# rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa
# prezentacji, bo to ona zna konta.
tenant: str = ""
query: str = Field(..., min_length=1, description="Szukana fraza.")
field: str = Field("name", description="Po którym polu szukać.")
+6 -2
View File
@@ -14,6 +14,7 @@ from app.models import QueryRequest, QueryResponse
class QueryService:
def __init__(self, data_client: DataClient | None = None) -> None:
self._wstrzykniety = data_client is not None
self.data = data_client or DataClient()
def handle(self, req: QueryRequest) -> QueryResponse:
@@ -21,8 +22,11 @@ class QueryService:
value = req.query.strip()
key = req.field.strip().lower()
# 2) zapytanie w dół do warstwy bazodanowej
raw = self.data.search(key=key, value=value, exact=req.exact, limit=req.limit)
# 2) zapytanie w dół do warstwy bazodanowej — do PULI KONTA, jeśli podano.
# Klient budowany na żądanie, bo pula wynika z tego, kto pyta, a nie ze
# stanu procesu. Wstrzyknięty klient (testy) ma pierwszeństwo.
dane = self.data if (self._wstrzykniety or not req.tenant) else DataClient(tenant=req.tenant)
raw = dane.search(key=key, value=value, exact=req.exact, limit=req.limit)
# 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować)
results = raw.get("rows", [])
+5 -1
View File
@@ -4,7 +4,11 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Do obrazu wchodzi WYŁĄCZNIE kod aplikacji. `COPY . .` wnosiło też testy,
# README i skrypty pomocnicze — a plik testowy nazywa funkcje wprost, więc
# w obrazie zbudowanym bez danego modułu byłby dokładnie tym śladem, którego
# wydzielenie ma się pozbyć. Przy okazji obraz jest mniejszy.
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -40,6 +40,29 @@ class LogicClient:
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)
@@ -97,61 +120,6 @@ class LogicClient:
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 prompt(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
provider: str | None = None, model: str | None = None,
) -> dict[str, Any]:
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
`provider` jest potrzebny dla budżetu „maksymalny kontekst modelu": limit
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso,
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
"model": model,
}
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return self._post("/chart/prompt", payload, max(settings.http_timeout, 60.0))
def horoscope(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", provider: str | None = None, model: str | None = None,
from_date: str | None = None, to_date: str | None = None,
) -> dict[str, Any]:
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
Generowanie tekstu trwa (zwłaszcza na modelu lokalnym), stąd długi timeout.
"""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso,
"lat": lat, "lon": lon, "budget": budget,
}
if provider:
payload["provider"] = provider
if model:
payload["model"] = model
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return self._post("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
def horoscope_stream(self, payload: dict[str, Any]):
"""Strumień postępu pisania horoskopu (NDJSON) — przekazywany do przeglądarki.
Timeout jest długi, bo generowanie trwa; strumień i tak niesie heartbeat,
więc cisza na łączu nie zostanie wzięta za zerwanie.
Idzie przez szyfrowane łącze jak reszta ruchu w dół (PRE-16). Bez tego przy
włączonym `LINK_ENCRYPTION_REQUIRED` serwer odrzuciłby nieszyfrowane żądanie
i okno postępu przestałoby działać. `stream_lines` pieczętuje żądanie i
odszyfrowuje odpowiedź ramka po ramce, zachowując dostarczanie na żywo.
"""
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
yield from link_crypto.stream_lines(
client, f"{self.base_url}/chart/horoscope/stream",
payload=payload, headers=_auth_headers(), link=_link())
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:
@@ -185,12 +153,6 @@ class LogicClient:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(), link=_link())
def llm_models(self) -> dict[str, Any]:
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}/llm/models",
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,
@@ -0,0 +1,10 @@
"""Ekrany aplikacji — jeden moduł na ekran.
Ekran to trasy plus szablon. Import modułu rejestruje jego trasy (dekoratory
`@app.get`/`@app.post` wykonują się przy imporcie), więc o tym, CO aplikacja
umie, decyduje lista importów w `main.py` — a nie warunki rozsiane po kodzie.
Dzięki temu produkt buduje się przez ZŁOŻENIE: inny zestaw importów i inny
zestaw plików w obrazie to inny program, a nie ten sam program z wyłączonymi
kawałkami. Ekranu, którego moduł nie wszedł do obrazu, nie ma jak włączyć.
"""
@@ -0,0 +1,118 @@
"""Horoskop: pozycje, osie, domy, aspekty i kosmogram (strona główna)."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
chartwheel_mod,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_perms,
_limit_options,
_landing,
_build_utc,
_logic_error,
)
# ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse)
def chart_form(request: Request):
# Korzeń jest dostępny dla każdego zalogowanego, ale ekran „Horoskop" już nie.
# Bez tego konto bez uprawnienia do horoskopu dostawałoby 404 pod adresem
# głównym — czyli wyglądałoby to na zepsuty program, a nie na węższy zestaw.
if "chart" not in _perms(request):
target = _landing(request)
if target:
return RedirectResponse(target, status_code=303)
# Ta sama odpowiedź co bramka dla trasy bez uprawnienia. Poprzednia treść
# mówiła o „przypisanych funkcjach" i o „osobie, która zakładała konto",
# czyli wprost: funkcje są komuś przydzielane, jest ktoś, kto to robi,
# a twoje konto zostało okrojone.
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
**rozszerzenia.kontekst_szablonu(request)},
)
@app.post("/", response_class=HTMLResponse)
def chart_compute(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
aspect_orb: float = Form(8.0), # PRE-06: konfigurowalny orb aspektów
aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
stations: bool = Form(False),
zodiac: str = Form("tropical"),
tables: bool = Form(False),
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "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,
"wheel_orientation": wheel_orientation}
form = _limit_options(request, form)
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=form["house_system"], house_systems=form["house_systems"],
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
aspect_minor=form["aspect_minor"],
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
)
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
orientation=form["wheel_orientation"])
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
if "extra_charts" in _perms(request):
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "chart.html", ctx)
# ── zgłoszenie do katalogu ─────────────────────────────────────────────────
# Ekran zgłasza siebie, swoje trasy i swoje zasoby. Wszystko o tym ekranie jest
# w tym pliku — więc obraz bez tego pliku nie wie o nim NIC.
features.zarejestruj(
ekran=Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.",
"Ekrany", "/", kolejnosc=10),
trasy={("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
("POST", "/"): "chart"},
zasoby={"s-chart.css": "chart"},
)
features.zarejestruj(rozszerzenie=Feature(
"houses_compare", "Porównanie systemów domów",
"Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
"Rozszerzenia", kolejnosc=10))
features.zarejestruj(rozszerzenie=Feature(
"extra_charts", "Wykresy dodatkowe",
"Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia", kolejnosc=20))
features.zarejestruj(rozszerzenie=Feature(
"advanced_calc", "Obliczenia zaawansowane",
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
"Rozszerzenia", kolejnosc=30))
@@ -0,0 +1,81 @@
"""Interpretacje: wynik obliczeń szukany w bazach."""
from __future__ import annotations
from fastapi import Depends, Form, Request
from fastapi.responses import HTMLResponse, Response
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
security,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_build_utc,
_dozwolona_akcja,
_report_records,
_logic_error,
)
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
@app.get("/interpret", response_class=HTMLResponse)
def interpret_form(request: Request):
return templates.TemplateResponse(
request, "interpret.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
**rozszerzenia.kontekst_szablonu(request)},
)
@app.post("/interpret", response_class=HTMLResponse)
def interpret_run(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
group: bool = Form(False),
action: str = Form("report"),
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group, **dodatki}
action = _dozwolona_akcja(request, action, "report")
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
**rozszerzenia.kontekst_szablonu(request)}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
# Akcja może należeć do modułu opcjonalnego. `None` znaczy „nie jego",
# więc ekran robi to, co robi normalnie. Moduł może oddać słownik do
# kontekstu albo gotową odpowiedź — na przykład plik do pobrania.
wynik = rozszerzenia.obsluz(action, profil="natal", iso_utc=iso_utc,
lat=lat, lon=lon, dodatki=dodatki,
request=request, group=group)
if isinstance(wynik, Response):
return wynik
if wynik is not None:
ctx["wynik_dodatku"] = wynik
else:
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
security.audit_records(request, _report_records(ctx["result"]))
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "interpret.html", ctx)
features.zarejestruj(
ekran=Feature("interpret", "Interpretacje", "Interpretacja natalna z baz.",
"Ekrany", "/interpret", kolejnosc=20),
trasy={("GET", "/interpret"): "interpret", ("POST", "/interpret"): "interpret"},
zasoby={"s-interpret.css": "interpret"},
)
@@ -0,0 +1,78 @@
"""Kalendarz: oś czasu z technik wraz z interpretacjami."""
from __future__ import annotations
from fastapi import Depends, Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_build_utc,
_dozwolona_akcja,
_logic_error,
)
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
@app.get("/timeline", response_class=HTMLResponse)
def timeline_form(request: Request):
return templates.TemplateResponse(
request, "timeline.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
**rozszerzenia.kontekst_szablonu(request)},
)
@app.post("/timeline", response_class=HTMLResponse)
def timeline_run(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
from_date: str = Form(...),
to_date: str = Form(...),
action: str = Form("timeline"),
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "from_date": from_date, "to_date": to_date,
**dodatki}
action = _dozwolona_akcja(request, action, "timeline")
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
**rozszerzenia.kontekst_szablonu(request)}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
wynik = rozszerzenia.obsluz(action, profil="period", iso_utc=iso_utc,
lat=lat, lon=lon, dodatki=dodatki,
od=from_date, do=to_date)
if wynik is not None:
ctx["wynik_dodatku"] = wynik
else:
ctx["result"] = logic.timeline(
when_utc_iso=iso_utc, lat=lat, lon=lon,
from_date=from_date, to_date=to_date, interpret=True,
)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "timeline.html", ctx)
features.zarejestruj(
ekran=Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.",
"Ekrany", "/timeline", kolejnosc=30),
trasy={("GET", "/timeline"): "timeline", ("POST", "/timeline"): "timeline"},
zasoby={"s-timeline.css": "timeline"},
)
+105
View File
@@ -0,0 +1,105 @@
"""Konta i uprawnienia (PRE-27) — ekran wyłącznie administracyjny."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app import accounts as accounts_store
from app.features import Feature
from app.podstawa import (app, templates, security, features)
# ---------------- Konta i uprawnienia (PRE-27) ----------------
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
def _accounts_context(request: Request, error: str = "", done: str = "") -> dict:
# Problem z magazynem NIE MOŻE dawać gołego 500: to jedyny ekran, z którego
# administrator może go naprawić, więc musi na nim przeczytać, co i gdzie
# jest nie tak.
try:
users = accounts_store.all_users()
except accounts_store.AccountsUnavailable as e:
users, error = {}, str(e)
return {
"users": users,
"catalog": features.ALL,
"screens": features.SCREENS,
# Rejestr zawiera już wszystko, co ten obraz umie — łącznie z tym, co
# wniosły moduły opcjonalne. Administrator ma nadawać uprawnienia do
# funkcji, które REALNIE są, a nie do listy wpisanej kiedyś na sztywno.
"extras": features.EXTRAS,
"admin_login": security.app_user(),
"store_path": accounts_store.store_path(),
"error": error, "done": done,
}
@app.get("/accounts", response_class=HTMLResponse)
def accounts_view(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "accounts.html",
_accounts_context(request, error, done))
def _accounts_redirect(error: str = "", done: str = "") -> RedirectResponse:
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
powtórzyć zakładania ani kasowania konta."""
from urllib.parse import urlencode
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
return RedirectResponse(f"/accounts{'?' + q if q else ''}", status_code=303)
@app.post("/accounts/create")
def accounts_create(request: Request, login: str = Form(""), password: str = Form(""),
note: str = Form(""), granted: list[str] = Form([])):
try:
accounts_store.create(login, password, granted, note)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Założono konto „{login.strip()}”.")
@app.post("/accounts/update")
def accounts_update(request: Request, login: str = Form(...), password: str = Form(""),
note: str = Form(""), granted: list[str] = Form([])):
try:
accounts_store.update(login, granted=granted, password=password, note=note)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
changed = "uprawnienia i hasło" if password.strip() else "uprawnienia"
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
@app.post("/accounts/sesje")
def accounts_sesje(request: Request, login: str = Form(...)):
"""Unieważnia WSZYSTKIE otwarte sesje konta, na każdym urządzeniu.
Zmiana hasła robi to samo przy okazji, więc ta operacja jest na wypadek
„wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił zalogowany
komputer i nie chce wpisywać nowego hasła wszędzie."""
try:
accounts_store.bump_session(login)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Zamknięto wszystkie sesje konta „{login}”.")
@app.post("/accounts/delete")
def accounts_delete(request: Request, login: str = Form(...)):
try:
accounts_store.delete(login)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
# Ekran wyłącznie administracyjny — nie ma pozycji w katalogu funkcji, bo ADMIN
# nie jest uprawnieniem do nadania (patrz features.GRANTABLE).
features.zarejestruj(
nawigacja=("accounts", "/accounts", "Konta", features.ADMIN),
trasy={("GET", "/accounts"): features.ADMIN,
("POST", "/accounts/create"): features.ADMIN,
("POST", "/accounts/update"): features.ADMIN,
("POST", "/accounts/delete"): features.ADMIN,
("POST", "/accounts/sesje"): features.ADMIN},
zasoby={"s-accounts.css": features.ADMIN},
)
@@ -0,0 +1,79 @@
"""Logowanie i wylogowanie (LOG-34)."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.features import Feature
from app.podstawa import (features, app, templates, security, session)
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
# Zastępuje HTTP Basic, który nie miał wylogowania: przeglądarka zapamiętywała
# hasło i dosyłała je sama, więc serwer nie miał czego zapomnieć. Teraz to serwer
# decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
def _bezpieczne_dokad(dokad: str) -> str:
"""Adres powrotu po zalogowaniu, przepuszczony przez sito.
Bez tego `?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie
do przekierowywania ludzi gdzie indziej klasyczna droga do wyłudzenia hasła,
bo odsyłacz prowadzi z zaufanego adresu. Wpuszczamy wyłącznie ścieżki
wewnętrzne: jeden ukośnik na początku i ani jednego znaku, który mógłby
zacząć adres obcego serwera."""
dokad = (dokad or "").strip()
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
return "/"
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
@app.get("/logowanie", response_class=HTMLResponse)
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
# Zalogowanego nie ma po co pytać o hasło jeszcze raz.
if security.auth_enabled() and security.principal(request) is not None:
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
return templates.TemplateResponse(request, "logowanie.html",
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
@app.post("/logowanie")
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
dokad: str = Form("/")):
cel = _bezpieczne_dokad(dokad)
who = security.verify(login.strip(), haslo)
if who is None:
# JEDEN komunikat na złe hasło i nieznany login. Rozróżnienie mówiłoby,
# które konta istnieją — a to informacja warta czegoś dla zgadującego.
security.audit_login(request, login.strip(), udane=False)
return templates.TemplateResponse(
request, "logowanie.html",
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
status_code=401)
security.audit_login(request, who.login, udane=True)
odpowiedz = RedirectResponse(cel, status_code=303)
odpowiedz.set_cookie(session.COOKIE, security.issue_session(who.login),
max_age=session.max_age(),
**session.cookie_params(secure=security.cookies_secure()))
return odpowiedz
@app.post("/wyloguj")
def wyloguj(request: Request):
"""Kasuje ciasteczko sesji. To jest całe wylogowanie — natychmiastowe
i bez żadnego magazynu, bo przeglądarka nie ma już czego dosyłać.
POST, nie GET: pod adresem GET wystarczyłby obrazek na obcej stronie, żeby
wylogować kogoś bez jego wiedzy. Uciążliwość, nie włamanie ale darmowa
do uniknięcia."""
security.audit_logout(request)
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
odpowiedz.delete_cookie(session.COOKIE, **session.cookie_params(
secure=security.cookies_secure()))
return odpowiedz
# Wyjście musi być dostępne dla każdego zalogowanego, niezależnie od tego, co mu
# przyznano — konto bez ani jednego ekranu też ma prawo wyjść.
features.zarejestruj(
trasy={("GET", "/logowanie"): None, ("POST", "/logowanie"): None,
("POST", "/wyloguj"): None},
)
@@ -0,0 +1,58 @@
"""Wyszukiwarka miejsca i strefa czasowa (proxy OSM/Nominatim)."""
from __future__ import annotations
from datetime import timezone
from fastapi import HTTPException, Query
import httpx
from app.features import Feature
from app.podstawa import (features, app, geocode)
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
@app.get("/geocode")
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
try:
return {"results": geocode.search(q)}
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/reverse")
def geocode_reverse(lat: float, lon: float):
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
try:
return geocode.reverse(lat, lon)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/timezone")
def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"):
"""Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03).
Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404,
gdy strefy nie da się ustalić front zostawia wtedy ręczny offset."""
from app import timezone as tzmod
res = tzmod.resolve(lat, lon, date, time)
if not res:
raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.")
return res
# Wyszukiwarka miejsca siedzi na kilku ekranach naraz, więc jej zasoby otwiera
# KTÓREKOLWIEK z nich — wymaganie wszystkich byłoby bez sensu.
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
_FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
_KOLO = frozenset({"chart", "compile"})
features.zarejestruj(
trasy={("GET", "/geocode"): None, ("GET", "/reverse"): None,
("GET", "/timezone"): None},
zasoby={"geo.js": _LOKALIZACJA, "now.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.css": _LOKALIZACJA,
"formsync.js": _FORMULARZE,
"wheelzoom.js": _KOLO, "wheeltip.js": _KOLO,
"copy.js": frozenset({"interpret", "timeline"})},
)
+170
View File
@@ -0,0 +1,170 @@
"""Pliki baz: wybór, wgrywanie, archiwizacja i reguły przyjmowania (DAN-27)."""
from __future__ import annotations
from fastapi import File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
import base64
import httpx
from app import files_state
from app.features import Feature
from app.podstawa import (app, templates, logic, features, _perms, _logic_error)
# Etykiety stanów pliku dla szablonu. Rejestrowane TUTAJ, a nie w podstawie:
# należą do tego ekranu, a produkt, który go nie ma, nie ma po co ich znać.
templates.env.globals["STATUS_LABELS"] = files_state.LABELS
# ---------------- Pliki baz (DAN-27) ----------------
# Trzy poziomy dostępu, opisane w features.ROUTES:
# „files" — widzi listę i decyduje, z czego program korzysta,
# „files_input" — dokłada wgrywanie i ARCHIWIZACJĘ (plik zostaje zamrożony
# ze znacznikiem czasu, znika tylko z użytku),
# administrator — kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.
#
# Kwarantanna (plik wgrany, ale odrzucony przez walidację) jest odsiewana W WARSTWIE
# DANYCH przy `for_admin=False`. Nie filtrujemy jej tutaj ani w szablonie: gdyby
# takie pliki dochodziły do przeglądarki, wystarczyłby podgląd źródła, żeby poznać
# reguły — a te ma znać wyłącznie administrator.
def _files_context(request: Request, error: str = "", done: str = "") -> dict:
is_admin = features.ADMIN in _perms(request)
try:
data = logic.files_list(for_admin=is_admin)
except httpx.HTTPError as e:
return {"files": [], "rules": {}, "is_admin": is_admin,
"error": _logic_error(e), "done": ""}
return {"files": data.get("files") or [], "rules": data.get("rules") or {},
"is_admin": is_admin, "error": error, "done": done}
@app.get("/files", response_class=HTMLResponse)
def files_view(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "files.html",
_files_context(request, error, done))
def _files_redirect(error: str = "", done: str = "") -> RedirectResponse:
from urllib.parse import urlencode
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
return RedirectResponse(f"/files{'?' + q if q else ''}", status_code=303)
def _who(request: Request) -> str:
return getattr(request.state, "user", "") or "-"
@app.post("/files/use")
def files_use(request: Request, path: str = Form(...), use: str = Form("")):
"""Włącza albo odstawia bazę. Włączenie przechodzi przez bramkę walidacji
w warstwie danych odmowa wraca BEZ POWODU, bo powód zdradzałby reguły."""
want = files_state.ACTIVE if use.strip().lower() in {"1", "true", "on", "tak"} \
else files_state.READY
try:
logic.files_status(path, want, by=_who(request))
except httpx.HTTPStatusError as e:
detail = _http_detail(e)
if features.ADMIN not in _perms(request):
detail = "Tego pliku nie da się teraz włączyć do użytku."
return _files_redirect(error=detail)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done="Zapisano." if want == files_state.READY
else "Plik włączony do użytku.")
@app.post("/files/upload")
async def files_upload(request: Request, upload: UploadFile = File(...)):
"""Wgranie nowej bazy. Plik zostaje NIEZALEŻNIE od wyniku walidacji —
nie tracimy niczego, co ktoś wgrał."""
raw = await upload.read()
if not raw:
return _files_redirect(error="Pusty plik.")
try:
out = logic.files_upload(upload.filename or "plik.xlsx",
base64.b64encode(raw).decode("ascii"), by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
# DOKŁADNIE ten sam komunikat niezależnie od wyniku sprawdzenia. Dwa różne
# komunikaty były wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać
# odpowiedź, żeby odgadnąć reguły, które ma znać wyłącznie administrator.
# Poprzednia treść mówiła przy tym wprost, że istnieje „administrator", który
# musi plik „zatwierdzić" — czyli że konto wgrywającego jest ograniczone
# i że plik przechodzi przez jakąś bramkę.
return _files_redirect(done=f"Wgrano „{out.get('name')}”.")
@app.post("/files/archive")
def files_archive(request: Request, path: str = Form(...)):
"""Archiwizacja: plik ZOSTAJE, zamrożony, ze znacznikiem czasu — znika tylko
z użytku. To najdalej idąca operacja dostępna osobie wgrywającej dane."""
try:
logic.files_status(path, files_state.ARCHIVED, by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Zarchiwizowano „{path}”. Plik został zachowany.")
@app.post("/files/restore")
def files_restore(request: Request, path: str = Form(...)):
try:
logic.files_status(path, files_state.READY, by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Przywrócono „{path}” z archiwum.")
@app.post("/files/delete")
def files_delete(request: Request, path: str = Form(...)):
try:
logic.files_delete(path)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Skasowano „{path}” bezpowrotnie.")
@app.post("/files/rules")
def files_rules(request: Request, extensions: str = Form(".xlsx"),
max_size_mb: float = Form(50), min_rows: int = Form(1),
required_columns: str = Form(""),
reject_duplicate_content: str = Form("")):
rules = {
"extensions": [e.strip() for e in extensions.split(",") if e.strip()],
"max_size_mb": max_size_mb,
"min_rows": min_rows,
"required_columns": [c.strip() for c in required_columns.split(",") if c.strip()],
"reject_duplicate_content": reject_duplicate_content.strip().lower()
in {"1", "true", "on", "tak"},
}
try:
logic.files_rules(rules)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done="Zapisano reguły walidacji.")
def _http_detail(e: httpx.HTTPStatusError) -> str:
try:
return str(e.response.json().get("detail") or e)
except Exception: # noqa: BLE001
return str(e)
features.zarejestruj(
ekran=Feature("files", "Pliki", "Wybór plików, z których korzysta program.",
"Ekrany", "/files", kolejnosc=70),
# Kasowanie, przywracanie i REGUŁY PRZYJMOWANIA to wyłącznie administrator —
# o istnieniu reguł nikt poza nim nie ma skąd wiedzieć (DAN-27).
trasy={("GET", "/files"): "files", ("POST", "/files/use"): "files",
("POST", "/files/upload"): "files_input",
("POST", "/files/archive"): "files_input",
("POST", "/files/restore"): features.ADMIN,
("POST", "/files/delete"): features.ADMIN,
("POST", "/files/rules"): features.ADMIN},
zasoby={"s-files.css": "files"},
)
features.zarejestruj(rozszerzenie=Feature(
"files_input", "Wgrywanie i archiwizacja plików",
"Dodawanie nowych plików i wycofywanie ich z użytku (plik zostaje, zamrożony, "
"ze znacznikiem czasu). Kasować może wyłącznie administrator.",
"Rozszerzenia", kolejnosc=40))
@@ -0,0 +1,96 @@
"""Skompiluj: zbiorczy raport z policzonych części (PRE-23), wraz z PDF-em."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
chartwheel_mod,
DEFAULT_LOCATION_LABEL,
default_form,
_perms,
_limit_options,
_build_utc,
_logic_error,
)
# ---------------- Skompiluj: zbiorczy raport (PRE-23) ----------------
@app.get("/compile", response_class=HTMLResponse)
def compile_form(request: Request):
return templates.TemplateResponse(
request, "compile.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/compile", response_class=HTMLResponse)
def compile_build(
request: Request,
person: str = Form(""),
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu
aspect_orb: float = Form(8.0), # PRE-06 — te same ustawienia aspektów
aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
zodiac: str = Form("tropical"),
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
):
"""Składa raport: horoskop liczymy TU NA NOWO, a części z przeglądarki (interpretacja
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
Horoskop liczymy ponownie (czysta funkcja wejścia tanio powtórzyć, bez
trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które
wybrano przy horoskopie (stacje, tabele, porównanie domów) inaczej
podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między
zakładkami przez formsync.
"""
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "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,
"wheel_orientation": wheel_orientation}
form = _limit_options(request, form)
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=form["house_system"], house_systems=form["house_systems"],
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
aspect_minor=form["aspect_minor"],
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
)
from app import chartwheel
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
orientation=form["wheel_orientation"])
if "extra_charts" in _perms(request):
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "compile.html", ctx)
features.zarejestruj(
ekran=Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.",
"Ekrany", "/compile", kolejnosc=60),
trasy={("GET", "/compile"): "compile", ("POST", "/compile"): "compile"},
zasoby={"s-compile.css": "compile", "compile.js": "compile"},
)
@@ -0,0 +1,41 @@
"""Sygnifikatory: wyszukiwarka po bazach."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, security, _logic_error)
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
@app.get("/significators", response_class=HTMLResponse)
def significators_form(request: Request):
return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}})
@app.post("/significators", response_class=HTMLResponse)
def significators_search(
request: Request,
query: str = Form(...),
field: str = Form("name"),
exact: bool = Form(False),
limit: int = Form(25),
):
form = {"query": query, "field": field, "exact": exact, "limit": limit}
ctx: dict = {"form": form, "result": None, "error": None}
try:
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
# ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści
security.audit_records(request, len((ctx["result"] or {}).get("rows") or []))
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "significators.html", ctx)
features.zarejestruj(
ekran=Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach.",
"Ekrany", "/significators", kolejnosc=50),
trasy={("GET", "/significators"): "significators",
("POST", "/significators"): "significators"},
zasoby={"s-significators.css": "significators"},
)
@@ -0,0 +1,58 @@
"""Synastria: porównanie dwóch horoskopów."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, _build_utc, _logic_error)
# ---------------- Synastria (technika relacyjna) ----------------
@app.get("/synastry", response_class=HTMLResponse)
def synastry_form(request: Request):
# domyślne liczby, żeby pola number nie były puste (puste → błąd przy wysyłce)
form = {"a_tz_offset": 0, "a_lat": 0, "a_lon": 0, "b_tz_offset": 0, "b_lat": 0, "b_lon": 0,
"aspect_orb": 8, "aspect_luminary_bonus": 2}
return templates.TemplateResponse(request, "synastry.html", {"result": None, "form": form})
@app.post("/synastry", response_class=HTMLResponse)
def synastry_run(
request: Request,
a_person: str = Form(""), a_date: str = Form(...), a_time: str = Form(...),
a_tz_offset: float = Form(0.0), a_lat: float = Form(0.0), a_lon: float = Form(0.0),
b_person: str = Form(""), b_date: str = Form(...), b_time: str = Form(...),
b_tz_offset: float = Form(0.0), b_lat: float = Form(0.0), b_lon: float = Form(0.0),
zodiac: str = Form("tropical"),
aspect_orb: float = Form(8.0), aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
):
"""Synastria (PRE-04): dwie osoby → aspekty między ich horoskopami."""
form = {"a_person": a_person, "a_date": a_date, "a_time": a_time, "a_tz_offset": a_tz_offset,
"a_lat": a_lat, "a_lon": a_lon, "b_person": b_person, "b_date": b_date,
"b_time": b_time, "b_tz_offset": b_tz_offset, "b_lat": b_lat, "b_lon": b_lon,
"zodiac": zodiac, "aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor}
ctx: dict = {"form": form, "result": None, "error": None}
try:
iso_a, _ = _build_utc(a_date, a_time, a_tz_offset)
iso_b, _ = _build_utc(b_date, b_time, b_tz_offset)
ctx["result"] = logic.synastry(
{"when_utc": iso_a, "lat": a_lat, "lon": a_lon},
{"when_utc": iso_b, "lat": b_lat, "lon": b_lon},
zodiac=zodiac, aspect_orb=aspect_orb,
aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor,
)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "synastry.html", ctx)
features.zarejestruj(
ekran=Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.",
"Ekrany", "/synastry", kolejnosc=40),
trasy={("GET", "/synastry"): "synastry", ("POST", "/synastry"): "synastry"},
zasoby={"s-synastry.css": "synastry"},
)
@@ -0,0 +1,32 @@
"""Ustawienia: podgląd baz widocznych na udziale (DAN-15/PRE-09)."""
from __future__ import annotations
from fastapi import Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, _logic_error)
# ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ----------------
@app.get("/settings", response_class=HTMLResponse)
def settings_view(request: Request):
"""Podgląd baz dostępnych na udziale + które biorą udział w interpretacji.
Same metadane (nazwa, rozmiar, data) bez treści baz. Przełączniki
DEKLARATYWNE (`DISABLED_BASES`), bo warstwa danych nie ma trwałego zapisu:
udział jest read-only, a cache to emptyDir. Zapis do pliku ginąłby po
restarcie, po cichu włączając z powrotem wyłączoną bazę."""
ctx: dict = {"result": None, "error": None}
try:
ctx["result"] = logic.bases()
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "settings.html", ctx)
features.zarejestruj(
ekran=Feature("settings", "Ustawienia", "Podgląd plików widocznych na udziale.",
"Ekrany", "/settings", kolejnosc=80),
trasy={("GET", "/settings"): "settings"},
zasoby={"s-settings.css": "settings"},
)
+61 -109
View File
@@ -21,6 +21,8 @@ from __future__ import annotations
from dataclasses import dataclass
from app import rozszerzenia
ADMIN = "admin" # zakładanie kont i nadawanie uprawnień — nie do nadania z UI
@@ -31,97 +33,76 @@ class Feature:
hint: str
group: str
href: str = "" # tylko ekrany; rozszerzenia nie mają własnej zakładki
kolejnosc: int = 0 # pozycja w nawigacji; nie zależy od kolejności importów
# ── ekrany (zakładki) ────────────────────────────────────────────────────
SCREENS: tuple[Feature, ...] = (
Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.", "Ekrany", "/"),
Feature("interpret", "Interpretacje", "Interpretacja natalna z baz i od modelu.", "Ekrany", "/interpret"),
Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.", "Ekrany", "/timeline"),
Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.", "Ekrany", "/synastry"),
Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach interpretacyjnych.", "Ekrany", "/significators"),
Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.", "Ekrany", "/compile"),
Feature("files", "Pliki", "Wybór baz, z których korzysta program.", "Ekrany", "/files"),
Feature("settings", "Ustawienia", "Podgląd baz i konfiguracji modelu.", "Ekrany", "/settings"),
)
# ── rejestr: katalog składa się z tego, co zgłoszą ekrany ────────────────
# Dawniej wszystkie funkcje były wypisane TUTAJ. To znaczyło, że obraz produktu,
# który części z nich nie ma, i tak niósł ich nazwy — czyli spis funkcji, których
# nie ma jak włączyć. Teraz ekran zgłasza siebie sam, przy imporcie swojego
# modułu, więc katalog opisuje dokładnie ten produkt, który się zbudowało.
#
# `kolejnosc` jest jawna, bo o zawartości decyduje lista importów w `main.py`,
# a kolejność importów nie może rządzić kolejnością zakładek w nawigacji.
SCREENS: list[Feature] = []
# Pozycje nawigacji dla uprawnień NIENADAWALNYCH — tych, których nie ma w
# GRANTABLE, więc nie da się ich przyznać z żadnego ekranu. Osobno od SCREENS
# i wraz z wymaganym uprawnieniem, żeby szablon nie musiał go wypowiadać: wpisane
# w szablonie na sztywno zostawiało nazwę ekranu i martwy odsyłacz w produkcie,
# który tego ekranu nie ma.
NAWIGACJA_SPECJALNA: list[tuple[str, str, str, str]] = [] # (klucz, adres, etykieta, uprawnienie)
EXTRAS: list[Feature] = []
ALL: tuple[Feature, ...] = ()
BY_KEY: dict[str, Feature] = {}
GRANTABLE: frozenset[str] = frozenset()
# ── rozszerzenia (poziomy złożoności wewnątrz ekranów) ───────────────────
EXTRAS: tuple[Feature, ...] = (
Feature("houses_compare", "Porównanie systemów domów",
"Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
"Rozszerzenia"),
Feature("extra_charts", "Wykresy dodatkowe",
"Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia"),
Feature("advanced_calc", "Obliczenia zaawansowane",
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
"Rozszerzenia"),
Feature("ai", "Generowanie tekstu przez model",
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
"Rozszerzenia"),
Feature("files_input", "Wgrywanie i archiwizacja baz",
"Dodawanie nowych plików baz i wycofywanie ich z użytku (plik zostaje, "
"zamrożony, ze znacznikiem czasu). Kasować może wyłącznie administrator.",
"Rozszerzenia"),
Feature("export", "Eksport plików",
"Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
)
ROUTES: dict[tuple[str, str], str | None] = {}
STATIC: dict[str, frozenset[str] | str | None] = {}
ALL: tuple[Feature, ...] = SCREENS + EXTRAS
BY_KEY: dict[str, Feature] = {f.key: f for f in ALL}
GRANTABLE: frozenset[str] = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
def _przelicz() -> None:
global ALL, BY_KEY, GRANTABLE
SCREENS.sort(key=lambda f: f.kolejnosc)
EXTRAS.sort(key=lambda f: f.kolejnosc)
ALL = tuple(SCREENS) + tuple(EXTRAS)
BY_KEY = {f.key: f for f in ALL}
GRANTABLE = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
def zarejestruj(*, ekran: Feature | None = None, rozszerzenie: Feature | None = None,
trasy: dict[tuple[str, str], str | None] | None = None,
zasoby: dict | None = None,
nawigacja: tuple[str, str, str, str] | None = None) -> None:
"""Zgłoszenie funkcji, jej tras i jej zasobów.
Idempotentne po kluczu: `export` zgłaszają dwa ekrany (raport do PDF-a
i wyniki do arkusza), a wpis ma powstać raz."""
for pozycja, zbior in ((ekran, SCREENS), (rozszerzenie, EXTRAS)):
if pozycja and pozycja.key not in {f.key for f in zbior}:
zbior.append(pozycja)
if nawigacja and nawigacja[0] not in {p[0] for p in NAWIGACJA_SPECJALNA}:
NAWIGACJA_SPECJALNA.append(nawigacja)
ROUTES.update(trasy or {})
STATIC.update(zasoby or {})
_przelicz()
# Funkcje wnoszone przez moduły opcjonalne — zgłaszane tak samo jak ekrany,
# tylko przez neutralny most, żeby katalog nie musiał ich wymieniać z nazwy.
for _krotka in rozszerzenia.funkcje():
zarejestruj(rozszerzenie=Feature(*_krotka))
zarejestruj(trasy=rozszerzenia.trasy(), zasoby=rozszerzenia.zasoby())
# ── mapa trasa → wymagane uprawnienie ────────────────────────────────────
# None = dostępne każdemu zalogowanemu. Dotyczy to pomocników (geokoder, strefa
# czasowa, health), które same w sobie NICZEGO nie zdradzają o funkcjach programu.
ROUTES: dict[tuple[str, str], str | None] = {
("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
("POST", "/"): "chart",
("GET", "/interpret"): "interpret",
("POST", "/interpret"): "interpret",
("GET", "/timeline"): "timeline",
("POST", "/timeline"): "timeline",
("GET", "/synastry"): "synastry",
("POST", "/synastry"): "synastry",
("GET", "/significators"): "significators",
("POST", "/significators"): "significators",
("GET", "/compile"): "compile",
("POST", "/compile"): "compile",
("POST", "/compile/pdf"): "export",
("GET", "/settings"): "settings",
# Zarządzanie plikami baz (DAN-27). Trzy poziomy: „files" wybiera, z czego
# program korzysta; „files_input" dokłada wgrywanie i archiwizację;
# kasowanie, przywracanie i REGUŁY WALIDACJI to wyłącznie administrator —
# o istnieniu walidacji nikt poza nim nie ma skąd wiedzieć.
("GET", "/files"): "files",
("POST", "/files/use"): "files",
("POST", "/files/upload"): "files_input",
("POST", "/files/archive"): "files_input",
("POST", "/files/restore"): ADMIN,
("POST", "/files/delete"): ADMIN,
("POST", "/files/rules"): ADMIN,
("POST", "/horoscope/stream"): "ai",
("GET", "/accounts"): ADMIN,
("POST", "/accounts/create"): ADMIN,
("POST", "/accounts/update"): ADMIN,
("POST", "/accounts/delete"): ADMIN,
("GET", "/geocode"): None,
("GET", "/reverse"): None,
("GET", "/timezone"): None,
("GET", "/health"): None,
# Wylogowanie musi być dostępne dla każdego zalogowanego, niezależnie od tego,
# co mu przyznano — konto bez ani jednego ekranu też ma prawo wyjść.
("GET", "/logowanie"): None,
("POST", "/logowanie"): None,
("POST", "/wyloguj"): None,
("POST", "/accounts/sesje"): ADMIN,
}
# ── zasoby statyczne ─────────────────────────────────────────────────────
# Skrypt i arkusz stylów są CZĘŚCIĄ funkcji, nie jej dekoracją. Nazwa pliku jest
# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `models.js`
# wymienia dostawców modeli, `compile.js` — składanie raportu. Dlatego każdy
# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `compile.js`
# zdradza składanie raportu, a `wheelzoom.js` — istnienie kosmogramu. Dlatego każdy
# zasób przechodzi przez tę samą bramkę co ekrany.
#
# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
@@ -131,36 +112,6 @@ _FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
_KOLO = frozenset({"chart", "compile"})
STATIC: dict[str, frozenset[str] | str | None] = {
# arkusze ekranów — jeden ekran, jedno uprawnienie
"s-chart.css": "chart",
"s-interpret.css": "interpret",
"s-timeline.css": "timeline",
"s-synastry.css": "synastry",
"s-significators.css": "significators",
"s-compile.css": "compile",
"s-files.css": "files",
"s-settings.css": "settings",
"s-accounts.css": ADMIN,
"x-ai.css": "ai",
# skrypty
"formsync.js": _FORMULARZE,
"now.js": _LOKALIZACJA,
"geo.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.css": _LOKALIZACJA,
"wheelzoom.js": _KOLO,
"wheeltip.js": _KOLO,
"copy.js": frozenset({"interpret", "timeline"}),
"compile.js": "compile",
"models.js": "ai",
"progress.js": "ai",
"natal.js": "ai",
"predictions.js": "ai",
# base.css celowo NIE MA tu wpisu: potrzebuje go ekran logowania, więc jest
# publiczny (PUBLIC_PATHS w security.py). Dlatego nie wolno w nim trzymać
# niczego, co nazywa jakąkolwiek funkcję.
}
def static_required(name: str) -> frozenset[str] | str | None:
@@ -192,7 +143,8 @@ def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
def screens_for(perms: frozenset[str]) -> list[Feature]:
"""Zakładki do pokazania w nawigacji — w stałej kolejności katalogu."""
"""Zakładki do pokazania w nawigacji — w kolejności zadeklarowanej, nie w tej,
w której akurat wykonały się importy."""
return [f for f in SCREENS if f.key in perms]
+1 -1
View File
@@ -1,7 +1,7 @@
"""Katalog systemów domów dla warstwy prezentacji — JEDNO źródło nazw.
Nazwy były zaszyte w czterech szablonach naraz (selektor na Horoskop", selektor
na Skompiluj", checkboxy porównania, nagłówki tabeli). Przy trzech systemach
w raporcie zbiorczym, checkboxy porównania, nagłówki tabeli). Przy trzech systemach
uchodziło to na sucho; przy dziesięciu rozjazd jest kwestią czasu wystarczy
dopisać system w jednym miejscu i zapomnieć o trzech pozostałych.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
"""Moduły opcjonalne — każdy podkatalog to jedna odłączalna część produktu.
O tym, co produkt umie, decyduje ZAWARTOŚĆ tego katalogu w obrazie. Most
(`app.rozszerzenia`) odkrywa moduły, przechodząc po podkatalogach, i nie zna
żadnej nazwy gdyby znał, nazwa nieobecnego modułu i tak jechałaby do obrazu,
w którym go nie ma.
Moduł może wnieść: pozycję do katalogu funkcji, trasy, zasoby, akcje formularza,
pola formularza, wpisy do kontekstu szablonu, własne szablony i własne statyki.
Wszystko jest opcjonalne most pyta o to, czego moduł nie ma, i dostaje pusto.
"""
@@ -0,0 +1,23 @@
"""Generowanie tekstu przez model językowy — moduł CAŁKOWICIE ODŁĄCZALNY.
DLACZEGO OSOBNY KATALOG, A NIE `{% if %}` W SZABLONACH. Warunek w szablonie
wystarcza, żeby funkcji nie było WIDAĆ, ale nie żeby jej NIE BYŁO. Plik szablonu
i tak leży w obrazie i i tak zawiera słowa opisujące funkcję `grep` po
kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie.
Astrololo zawiera ten katalog. Astroklient nie, i to jest jedyna różnica
między nimi po stronie funkcji astrologicznych. Dlatego granica MUSI być
katalogiem: co jest w środku, znika razem z nim, a co jest na zewnątrz, zostaje.
ZASADA DLA KOGOŚ, KTO TU DOPISUJE. Poza tym katalogiem nie wolno napisać ani
słowa o modelu, prompcie czy dostawcy nawet w komentarzu, nawet zaprzeczając.
Szablony wspólne mają neutralne gniazda (`_dodatki_*`), a plik wstawiany w
gniazdo sam sprawdza uprawnienie. Pilnuje tego test `test_ai_tylko_w_module.py`.
"""
from __future__ import annotations
from app.moduly.dodatki.katalog import AKCJE, FUNKCJA, TRASY, ZASOBY, katalog_dla, obsluz, pola
from app.moduly.dodatki.trasy import zamontuj
__all__ = ["AKCJE", "FUNKCJA", "TRASY", "ZASOBY", "katalog_dla", "obsluz", "pola",
"zamontuj"]
@@ -0,0 +1,91 @@
"""Co moduł wnosi do aplikacji: funkcję, trasy, zasoby, akcje i pola formularza.
Wszystko, co gdziekolwiek indziej musiałoby paść z nazwy, jest zebrane TUTAJ.
Reszta aplikacji zna wyłącznie neutralny most (`app.rozszerzenia`) i pyta go
o dane, nigdy o generowanie" czy „model".
"""
from __future__ import annotations
from typing import Any
import httpx
from fastapi import Form, Request
from app.moduly.dodatki import klient
# Pozycja w katalogu funkcji — surowa krotka, żeby nie importować `features`
# (to zamknęłoby cykl features → most → moduł → features).
FUNKCJA: tuple[tuple[str, str, str, str], ...] = (
("ai", "Generowanie tekstu przez model",
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
"Rozszerzenia"),
)
TRASY: dict[tuple[str, str], str] = {("POST", "/horoscope/stream"): "ai"}
ZASOBY: dict[str, str] = {
"x-ai.css": "ai",
"models.js": "ai",
"progress.js": "ai",
"natal.js": "ai",
"predictions.js": "ai",
}
# Akcje formularza wnoszone przez moduł: nazwa → wymagane uprawnienie.
AKCJE: dict[str, str] = {"prompt": "ai", "horoscope": "ai"}
# Wstrzykiwane przez `zamontuj` z main.py — moduł nie importuje aplikacji,
# bo to byłby cykl, a przy okazji zmusiłoby aplikację do wiedzy o module.
_logic: Any = None
_szablony: Any = None
def ustaw_zaleznosci(*, logic: Any, templates: Any) -> None:
global _logic, _szablony
_logic, _szablony = logic, templates
def pola(prompt_budget: str = Form("medium"),
llm_provider: str = Form(""),
llm_model: str = Form("")) -> dict:
"""Pola formularza, których wspólny handler nie może wymienić w sygnaturze.
FastAPI czyta pola z sygnatury, więc zadeklarowanie ich we wspólnym handlerze
wpisałoby nazwy `llm_provider` i `prompt_budget` w kod, który trafia także do
obrazu bez tego modułu. Zależność przenosi deklarację tutaj."""
return {"prompt_budget": prompt_budget, "llm_provider": llm_provider,
"llm_model": llm_model}
def _katalog() -> dict:
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
try:
return klient.modele(_logic)
except httpx.HTTPError:
return {"providers": {}, "defaults": {}}
def katalog_dla(request: Request) -> dict:
"""Katalog modeli do kontekstu szablonu — pusty, gdy konto nie ma uprawnienia.
Katalog trafia do strony jako blok JSON, więc bez tego ograniczenia nazwy
dostawców, modeli i rozmiary okien kontekstu byłyby w źródle każdej strony,
także dla kont, które o modelach nie mają prawa wiedzieć."""
perms = getattr(getattr(request.state, "principal", None), "permissions", frozenset())
return {"llm_catalog": _katalog() if "ai" in perms else {}}
def obsluz(akcja: str, *, profil: str, iso_utc: str, lat: float, lon: float,
dodatki: dict, od: str = "", do: str = "", **_reszta) -> dict | None:
"""Wykonanie akcji modułu. None znaczy „to nie moja akcja"."""
if akcja not in AKCJE:
return None
metoda = klient.prompt if akcja == "prompt" else klient.horoscope
argumenty = {"logic": _logic, "profile": profil, "when_utc_iso": iso_utc, "lat": lat, "lon": lon,
"budget": dodatki.get("prompt_budget", "medium"),
"provider": dodatki.get("llm_provider", ""),
"model": dodatki.get("llm_model", "")}
if profil == "period":
argumenty["from_date"], argumenty["to_date"] = od, do
return metoda(**argumenty)
@@ -0,0 +1,62 @@
"""Rozmowa z warstwą logiczną w sprawach tego modułu.
Metody nie siedzą na wspólnym kliencie, tylko tutaj: gdyby siedziały tam, ich
nazwy (`prompt`, `horoscope`, `llm_models`) byłyby w pliku, który jedzie do
KAŻDEGO obrazu także tam, gdzie tego modułu nie ma. Wspólny klient daje samą
drogę w dół (`wywolaj`, `pobierz`, `strumien`), z szyfrowaniem łącza i tokenem
międzywarstwowym; co nią pojedzie, jest sprawą modułu.
"""
from __future__ import annotations
from typing import Any
from app.config import settings
def prompt(logic: Any, *, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", from_date: str | None = None,
to_date: str | None = None, provider: str | None = None,
model: str | None = None) -> dict[str, Any]:
"""Gotowy prompt z wyliczeń (LOG-29/30).
`provider` jest potrzebny dla budżetu maksymalny kontekst modelu": limit
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso, "lat": lat, "lon": lon,
"budget": budget, "provider": provider, "model": model,
}
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return logic.wywolaj("/chart/prompt", payload, max(settings.http_timeout, 60.0))
def horoscope(logic: Any, *, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", provider: str | None = None,
model: str | None = None, from_date: str | None = None,
to_date: str | None = None) -> dict[str, Any]:
"""Napisany horoskop (LOG-31). Długi timeout, bo pisanie trwa —
zwłaszcza na modelu lokalnym."""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso, "lat": lat, "lon": lon,
"budget": budget,
}
if provider:
payload["provider"] = provider
if model:
payload["model"] = model
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return logic.wywolaj("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
def strumien(logic: Any, payload: dict[str, Any]):
"""Strumień postępu pisania (NDJSON), przekazywany do przeglądarki.
Strumień niesie heartbeat, więc cisza na łączu nie zostanie wzięta
za zerwanie."""
yield from logic.strumien("/chart/horoscope/stream", payload)
def modele(logic: Any) -> dict[str, Any]:
"""Katalog modeli per dostawca — podpowiedzi do pola wyboru."""
return logic.pobierz("/llm/models")
@@ -93,5 +93,5 @@
}
});
window.astrololoNatal = { read: read, clear: clear, key: KEY };
window.kosmogramNatal = { read: read, clear: clear, key: KEY };
})();
@@ -146,5 +146,5 @@
});
// Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy.
window.astrololoPredictions = { read: read, remove: remove, key: KEY };
window.kosmogramPredictions = { read: read, remove: remove, key: KEY };
})();
@@ -29,3 +29,10 @@
.progress-log li.log-ok { color: #7fd18b; }
.progress-log li.log-warn { color: #e0c060; }
.progress-log li.log-err { color: #ef6b6b; }
/* Generator promptu do LLM (LOG-29/30) */
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
background: #12132a; color: var(--ink); border: 1px solid var(--line);
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
@@ -0,0 +1,9 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
każdy sprawdza, czy jego kontener jest na stronie. #}
<script src="{{ static('natal.js') }}"></script>
<script src="{{ static('predictions.js') }}"></script>
{% endif %}
@@ -0,0 +1,4 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
@@ -0,0 +1,8 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
{# Części od modelu — wstawia przeglądarka z magazynu lokalnego. #}
<div id="reportNatal"></div>
<div id="reportPredictions"></div>
{% endif %}
@@ -0,0 +1,7 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
{% endif %}
@@ -0,0 +1,5 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}
<p class="muted small" id="natalNote"></p>{% endif %}
@@ -0,0 +1,9 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
<script src="{{ static('models.js') }}"></script>
<script src="{{ static('progress.js') }}"></script>
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
<script src="{{ static('natal.js') }}"></script>
{% endif %}
@@ -0,0 +1,4 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
@@ -0,0 +1,7 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
{% endif %}
@@ -0,0 +1,4 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
@@ -0,0 +1,9 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}
<script src="{{ static('models.js') }}"></script>
<script src="{{ static('progress.js') }}"></script>
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
<script src="{{ static('predictions.js') }}"></script>
{% endif %}
@@ -0,0 +1,4 @@
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
obrazu bez tego modułu. #}
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
@@ -36,4 +36,6 @@
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
</p>
<div id="promptResult">{% include "_prompt_result.html" %}</div>
{# Wspólny handler nie zna nazwy „prompt_result" — oddaje wynik pod neutralnym
„wynik_dodatku". Tłumaczenie nazwy należy do modułu. #}
<div id="promptResult">{% with prompt_result = wynik_dodatku %}{% include "_prompt_result.html" %}{% endwith %}</div>
@@ -0,0 +1,85 @@
"""Trasy wnoszone przez moduł. Bez tego katalogu w obrazie nie istnieją."""
from __future__ import annotations
import json
from typing import Any
import httpx
from fastapi import Form
from fastapi.responses import JSONResponse, StreamingResponse
from app.moduly.dodatki import katalog, klient
_logic: Any = None
_szablony: Any = None
_buduj_utc: Any = None
_blad: Any = None
def zamontuj(app, *, logic: Any, templates: Any, buduj_utc: Any, blad: Any,
**_reszta) -> None:
"""Podpięcie tras i przekazanie modułowi tego, czego potrzebuje.
Zależności przychodzą z zewnątrz, zamiast być importowane: moduł nie może
importować `app.main`, bo to cykl a przede wszystkim dlatego, że zależność
ma iść w JEDNĄ stronę. Aplikacja wie o istnieniu modułów opcjonalnych
(przez neutralny most), moduł nie wie nic o aplikacji.
"""
# `**_reszta` CELOWO: most podaje wszystkim modułom ten sam zestaw zależności,
# a każdy bierze z niego to, czego potrzebuje. Bez tego dołożenie zależności
# dla jednego modułu wywracałoby pozostałe.
global _logic, _szablony, _buduj_utc, _blad
_logic, _szablony, _buduj_utc, _blad = logic, templates, buduj_utc, blad
katalog.ustaw_zaleznosci(logic=logic, templates=templates)
@app.post("/horoscope/stream")
def horoscope_stream( # noqa: ANN202
profile: str = Form("natal"),
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
from_date: str = Form(""),
to_date: str = Form(""),
):
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który
wyrenderowałoby przeładowanie strony jedno źródło prawdy dla wyglądu.
"""
try:
iso_utc, _ = _buduj_utc(date, time, tz_offset)
except ValueError as e:
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"},
status_code=422)
payload: dict = {
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
}
if profile == "period" and from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
def relay():
try:
for raw in klient.strumien(_logic, payload):
try:
event = json.loads(raw)
except ValueError:
continue
if event.get("type") == "result":
event["html"] = _szablony.get_template("_prompt_result.html").render(
prompt_result=event.get("result") or {}
)
yield json.dumps(event, ensure_ascii=False) + "\n"
except httpx.HTTPError as e:
yield json.dumps({"type": "error", "message": _blad(e)},
ensure_ascii=False) + "\n"
return StreamingResponse(relay(), media_type="application/x-ndjson",
headers={"Cache-Control": "no-store",
"X-Accel-Buffering": "no"})
@@ -0,0 +1,13 @@
"""Wynoszenie treści na zewnątrz: arkusz z wynikami i raport w PDF-ie.
Moduł ODŁĄCZALNY. Jest w obrazie astrololo; w produktach, które nie mają wynosić
treści baz poza program, nie ma go wcale a nie ma go jak włączyć, bo nie ma
czego. Eksport wynosi NAJWIĘCEJ treści naraz, więc można, ale nie temu kontu"
i nie ma takiej możliwości" to dwie różne gwarancje.
"""
from __future__ import annotations
from app.moduly.eksport.katalog import AKCJE, FUNKCJA, TRASY, obsluz
from app.moduly.eksport.trasy import zamontuj
__all__ = ["AKCJE", "FUNKCJA", "TRASY", "obsluz", "zamontuj"]
@@ -0,0 +1,47 @@
"""Co moduł wnosi: funkcję, trasy i akcję formularza."""
from __future__ import annotations
from typing import Any
from fastapi.responses import Response
FUNKCJA: tuple[tuple[str, str, str, str], ...] = (
("export", "Eksport plików",
"Pobieranie raportu jako PDF i wyników jako arkusz.", "Rozszerzenia"),
)
TRASY: dict[tuple[str, str], str] = {("POST", "/compile/pdf"): "export"}
# Akcja formularza: nazwa → wymagane uprawnienie.
AKCJE: dict[str, str] = {"export": "export"}
# Wstrzykiwane przy montowaniu — moduł nie importuje aplikacji.
_logic: Any = None
_security: Any = None
_licz_rekordy: Any = None
def ustaw_zaleznosci(*, logic: Any, security: Any, licz_rekordy: Any) -> None:
global _logic, _security, _licz_rekordy
_logic, _security, _licz_rekordy = logic, security, licz_rekordy
def obsluz(akcja: str, *, iso_utc: str = "", lat: float = 0.0, lon: float = 0.0,
request: Any = None, group: bool = False, **_reszta) -> Response | None:
# Wszystko po `akcja` ma wartość domyślną CELOWO: most woła po kolei każdy
# moduł, także z ekranu, który tych danych nie ma. Brakujący argument
# wywracałby wtedy ekran, zamiast po prostu nie pasować do akcji.
if akcja not in AKCJE:
return None
# Arkusz roboczy z wynikami (DAN-23/PRE-10) — pobranie pliku, nie strona.
from app.moduly.eksport.arkusz import report_to_xlsx
report = _logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
# Eksport wynosi NAJWIĘCEJ treści baz naraz — tym bardziej ma zostawiać ślad
# w dzienniku (PRE-17): sama liczba rekordów, nigdy treść.
_security.audit_records(request, _licz_rekordy(report))
return Response(
content=report_to_xlsx(report),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
)
@@ -0,0 +1,114 @@
"""Trasy wnoszone przez moduł. Bez tego katalogu w obrazie nie istnieją."""
from __future__ import annotations
from typing import Any
import httpx
from fastapi.responses import JSONResponse, Response
from app.moduly.eksport import katalog
_logic: Any = None
_szablony: Any = None
_buduj_utc: Any = None
_blad: Any = None
_kolo: Any = None
def zamontuj(app, *, logic: Any, templates: Any, buduj_utc: Any, blad: Any,
security: Any = None, licz_rekordy: Any = None, kolo: Any = None,
**_reszta) -> None:
"""Podpięcie trasy i przekazanie zależności. Idą z zewnątrz, bo zależność ma
biec w JEDNĄ stronę: aplikacja wie o modułach opcjonalnych (przez neutralny
most), moduł nie wie nic o aplikacji."""
global _logic, _szablony, _buduj_utc, _blad, _kolo
_logic, _szablony, _buduj_utc, _blad = logic, templates, buduj_utc, blad
_kolo = kolo
katalog.ustaw_zaleznosci(logic=logic, security=security, licz_rekordy=licz_rekordy)
# Nazwy, których używa przeniesiona trasa. `chartwheel` to czysty rysownik
# bez stanu, więc importujemy go wprost — wstrzykiwanie ma sens dla rzeczy
# związanych z aplikacją (klient, szablony), nie dla funkcji rysujących.
from app import chartwheel as chartwheel_mod
_build_utc = buduj_utc
_logic_error = blad
@app.post("/compile/pdf")
def compile_pdf(payload: dict):
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
Wejście z przeglądarki, bo część kawałków (interpretacja natalna i predykcje)
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
zawierał dokładnie to, co widać na stronie.
Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVGPDF nie zna
naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
"""
from fastapi.responses import Response
from app.clients.render_client import RenderClient
data = payload.get("data") or {}
try:
iso_utc, label = _build_utc(
str(data.get("date") or ""), str(data.get("time") or ""),
float(data.get("tz_offset") or 0.0),
)
except (ValueError, TypeError) as e:
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
figures: list[dict] = []
warnings: list[str] = []
try:
chart = logic.positions(
when_utc_iso=iso_utc,
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
house_system=str(data.get("house_system") or "whole_sign"),
house_systems=[s for s in (data.get("house_systems") or []) if s],
aspect_orb=float(data.get("aspect_orb") or 8.0),
aspect_luminary_bonus=float(data.get("aspect_luminary_bonus") or 2.0),
aspect_minor=bool(data.get("aspect_minor")),
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
zodiac=str(data.get("zodiac") or "tropical"),
)
# Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu
# nie da się poznać, że podział jest z innego systemu, niż zamówiono.
warnings = [w for w in (chart.get("house_warnings") or []) if w]
from app import chartwheel
# Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
# w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
# zmienne CSS i font glifów muszą być wprost w rysunku.
for svg, caption in (
(chartwheel.render(chart, theme="print",
orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)),
"Kosmogram"),
(chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
):
if svg:
figures.append({"svg": svg, "caption": caption})
except httpx.HTTPError as e:
# Brak rysunków nie może zablokować raportu — tekst jest ważniejszy.
data = {**data, "wheel_error": _logic_error(e)}
report = {
"person": payload.get("person") or "",
"data": {**data, "moment_utc": label},
"figures": figures,
"warnings": warnings,
"natal": payload.get("natal") or {},
"predictions": payload.get("predictions") or [],
}
try:
pdf = RenderClient().pdf(report)
except httpx.HTTPError as e:
return JSONResponse(
{"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
status_code=502,
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
+284
View File
@@ -0,0 +1,284 @@
"""Podstawa aplikacji: wspólne obiekty i pomocnicy, z których korzystają ekrany.
Tu mieszka to, co ma KAŻDY produkt zbudowany z tego kodu: obiekt aplikacji,
szablony, klient warstwy logicznej, serwowanie zasobów i garść funkcji
pomocniczych. Ani jednej trasy ekranu te w `app/ekrany/*.py`, po jednym
module na ekran, a o tym, które wchodzą, decyduje lista importów w `main.py`.
Dlaczego tak: dopóki wszystko siedziało w jednym pliku, produkt bez tego ekranu"
znaczyło produkt z ekranem, tylko zablokowanym". Plik i tak jechał do obrazu,
razem z nazwami funkcji, których nie było jak włączyć.
"""
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
import hashlib
import logging
import re
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response
from fastapi.templating import Jinja2Templates
from app import rozszerzenia
from app import chartwheel as chartwheel_mod
from app import features
from app import produkt
from app import session
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
from app import geocode, security
from app.clients.logic_client import LogicClient
from app.config import DEFAULT_LOCATION_LABEL, default_form
# Automatyczna dokumentacja WYŁĄCZONA. /docs, /redoc i /openapi.json wypisują
# KOMPLET tras aplikacji — czyli spis wszystkich funkcji, łącznie z tymi, o których
# konto ograniczone nie ma prawa wiedzieć (PRE-27). Ochrona zakładek nic by nie dała,
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
app = FastAPI(title="astrololo · warstwa prezentacji",
docs_url=None, redoc_url=None, openapi_url=None)
# Katalogi modułów opcjonalnych idą PRZED wspólnym: moduł może dołożyć szablon,
# którego wspólny zestaw nie ma. Wspólne szablony wołają je przez neutralne
# gniazda `{% include "_dodatki_*" ignore missing %}` — bez modułu gniazdo jest
# po prostu puste, a nie „wyłączone".
templates = Jinja2Templates(directory=[*rozszerzenia.katalog_szablonow(), "app/templates"])
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _logic_error).
log = logging.getLogger("astrololo.prezentacja")
logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
# ── cache-busting statyki (PRE-27) ─────────────────────────────────────────
# Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache).
# Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL →
# przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej).
# Hash liczony raz na proces (lru_cache) — nowy pod po deployu = świeży hash.
_STATIC_DIR = Path("app/static")
_TYPY_ZASOBOW = {
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
}
def _bez_komentarzy(tresc: str, sufiks: str) -> str:
"""Usuwa komentarze z arkusza albo skryptu przed wysłaniem.
Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt, a te
komentarze opisują funkcje pełnym zdaniem po polsku łącznie z tym, czego
konto ograniczone NIE widzi i kto to widzi. Zostają w repozytorium, bo tam
potrzebne; do odpowiedzi nie mają po co trafiać.
Kasujemy tylko komentarze blokowe i CAŁE wiersze `//`. Komentarz na końcu
wiersza zostaje CELOWO: żeby go usunąć, trzeba rozpoznać, czy `//` nie jest
w napisie albo w wyrażeniu regularnym a pomyłka tutaj psuje skrypt po
cichu. Test pilnuje słownika zakazanych słów, więc reszta by i tak wyszła.
"""
tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
if sufiks == ".js":
tresc = re.sub(r"(?m)^[ \t]*//.*$\n?", "", tresc)
return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
@lru_cache(maxsize=None)
def _asset_body(name: str) -> bytes | None:
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
for katalog in (*rozszerzenia.katalog_zasobow(), str(_STATIC_DIR)):
korzen = Path(katalog).resolve()
sciezka = (Path(katalog) / name).resolve()
# Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
if korzen in sciezka.parents and sciezka.is_file():
break
else:
return None
surowe = sciezka.read_bytes()
sufiks = sciezka.suffix.lower()
# Kod obcy zostaje nietknięty: minifikowany plik potrafi mieć `//` w napisie
# albo w wyrażeniu regularnym, a on i tak nie mówi nic o naszym programie.
if sufiks in (".css", ".js") and not name.startswith("vendor/"):
try:
return _bez_komentarzy(surowe.decode("utf-8"), sufiks).encode("utf-8")
except UnicodeDecodeError:
return surowe
return surowe
@lru_cache(maxsize=None)
def _asset_version(name: str) -> str:
tresc = _asset_body(name)
# Hash liczony z treści WYSYŁANEJ, nie z pliku na dysku: inaczej zmiana
# samego komentarza podbijałaby adres, choć odpowiedź jest ta sama.
return hashlib.md5(tresc).hexdigest()[:8] if tresc is not None else "0"
def static_url(name: str) -> str:
return f"/static/{name}?v={_asset_version(name)}"
@app.get("/static/{nazwa:path}")
def static_file(nazwa: str):
"""Zasoby idą przez bramkę uprawnień jak każda inna trasa.
Dopóki `/static/` było w PUBLIC_PREFIXES, katalog omijał całe sprawdzanie:
ukrycie przycisku w szablonie nic nie dawało, bo obok leżał skrypt tej samej
funkcji, pod nazwą, którą wystarczyło zgadnąć. Uprawnienie zasobu sprawdza
middleware (features.static_required); tutaj zostaje samo wydanie pliku.
"""
tresc = _asset_body(nazwa)
if tresc is None:
raise HTTPException(status_code=404)
return Response(
tresc,
media_type=_TYPY_ZASOBOW.get(Path(nazwa).suffix.lower(), "application/octet-stream"),
# Adres niesie hash treści, więc wersja pod danym adresem jest niezmienna.
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
# Nazwa produktu z modułu, nie z szablonu: szablony są wspólne dla kilku
# produktów, a nazwa nie.
templates.env.globals["produkt"] = produkt.NAZWA
templates.env.globals["static"] = static_url
# Pozycje specjalne przefiltrowane uprawnieniem — szablon dostaje gotową listę
# i nie musi wiedzieć, czego która wymaga.
templates.env.globals["nav_extra"] = lambda request: [
(k, a, e) for k, a, e, u in features.NAWIGACJA_SPECJALNA
if features.can(_perms(request), u)]
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
templates.env.globals["house_label"] = house_label
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
def _perms(request: Request) -> frozenset[str]:
"""Uprawnienia zalogowanego konta — pusty zbiór, gdy nie wiadomo kto to."""
who = getattr(request.state, "principal", None)
return who.permissions if who is not None else frozenset()
# Szablony pytają o uprawnienia PRZEZ TE DWIE FUNKCJE, nigdy o konto wprost.
# Dzięki temu nie da się w szablonie napisać warunku na login — a to jest
# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym
# nikt już nie pamięta.
templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request))
# Kto jest zalogowany i czy logowanie w ogóle działa — pod przycisk wyjścia.
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
templates.env.globals["auth_on"] = security.auth_enabled
templates.env.globals["can"] = lambda request, key: key in _perms(request)
# Czy to konto ma uprawnienie NIENADAWALNE — czyli takie, którego nie da się
# przyznać z żadnego ekranu. Nazwa neutralna, bo szablon jest wspólny.
templates.env.globals["widok_diagnostyczny"] = lambda request: features.can(
_perms(request), features.ADMIN)
def _limit_options(request: Request, opts: dict) -> dict:
"""Ścina opcje, których konto nie ma prawa użyć — PO STRONIE SERWERA.
Ukrycie pola w formularzu chroni tylko przed przypadkiem. Kto zna nazwy pól,
wyśle je wprost a wtedy policzyłby funkcję, której mu nie przyznano, i to
jeszcze zobaczyłby w wyniku. Granicą jest ta funkcja, nie szablon.
Wartości zastępcze DOMYŚLNE, nie puste: konto ograniczone ma dostać
działający horoskop w podstawowej postaci, a nie komunikat o braku czegoś.
"""
perms = _perms(request)
if "advanced_calc" not in perms:
opts.update(stations=False, tables=False, aspect_minor=False, zodiac="tropical")
if "houses_compare" not in perms:
opts.update(house_system="whole_sign", house_systems=[],
wheel_orientation=chartwheel_mod.ASC_LEFT)
return opts
def _landing(request: Request) -> str | None:
"""Pierwszy ekran dostępny dla konta — dokąd odesłać z korzenia."""
allowed = features.screens_for(_perms(request))
return allowed[0].href if allowed else None
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
Zwraca (iso_utc, etykieta_czytelna). UTC = czas lokalny offset.
"""
local = datetime.fromisoformat(f"{date}T{time}")
utc = (local - timedelta(hours=tz_offset)).replace(tzinfo=timezone.utc)
label = utc.strftime("%Y-%m-%d %H:%M UTC")
return utc.isoformat(), label
# Akcja formularza → uprawnienie, którego wymaga. Akcji spoza tej mapy nie
# ograniczamy; akcja Z mapy, na którą konto nie ma prawa, jest traktowana jak
# NIEZNANA — czyli wraca do zachowania domyślnego ekranu.
# Akcje własne tego ekranu plus te, które wnoszą moduły opcjonalne. Aplikacja
# nie wypisuje ich z nazwy — gdyby wypisywała, nazwa funkcji byłaby w kodzie
# także tam, gdzie modułu nie ma.
_AKCJE_POD_UPRAWNIENIEM = {"export": "export", **rozszerzenia.akcje()}
def _dozwolona_akcja(request: Request, action: str, domyslna: str) -> str:
"""Akcja, której konto nie ma prawa użyć, zachowuje się jak literówka.
NIE zwracamy błędu ani 404: komunikat brak uprawnień do generowania" sam
w sobie mówiłby, że taka funkcja istnieje (PRE-27). Ukrycie przycisku chroni
tylko przed przypadkiem kto zna nazwę pola, wyśle wprost, i to TU musi
się zatrzymać."""
wymagane = _AKCJE_POD_UPRAWNIENIEM.get(action)
if wymagane and wymagane not in _perms(request):
return domyslna
return action
def _report_records(report: dict | None) -> int:
"""Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17).
Liczymy próbki we wszystkich fasetach: to one treścią z baz. Sama liczba,
nigdy treść dziennik ma pokazywać SKALĘ dostępu, nie powielać danych."""
total = 0
for o in (report or {}).get("objects") or []:
for f in o.get("facets") or []:
total += len(f.get("samples") or [])
return total
# Jedno zdanie dla wszystkich awarii — i CELOWO żadnego szczegółu.
# Poprzednia treść wypisywała na ekran nazwę endpointu (/chart/positions), nazwę
# podsystemu („silnik efemeryd"), nazwę gałęzi rozwojowej i — przez str(wyjątku
# httpx) — wewnętrzny adres z portem sąsiedniej usługi. Dla konta, które ma
# widzieć wyłącznie Pliki, to był komplet: co program liczy, że stoi za nim
# łańcuch usług, i gdzie zapukać.
_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
def _logic_error(e: Exception) -> str:
"""Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran.
Odsiew siedzi TUTAJ, a nie w siedemnastu miejscach wywołania: gdyby zależał
od tego, czy ktoś pamiętał przekazać uprawnienia, prędzej czy później jedno
wywołanie zostałoby pominięte i nie byłoby tego jak zauważyć.
"""
log.warning("warstwa logiczna: %s: %s", type(e).__name__, e)
return _AWARIA
features.zarejestruj(trasy={("GET", "/health"): None})
@app.get("/health")
def health() -> dict:
# Sama „ok" — nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa.
# Kubernetes i compose czytają wyłącznie kod odpowiedzi.
return {"status": "ok"}
+12
View File
@@ -0,0 +1,12 @@
"""Nazwa produktu — to, co widzi człowiek w tytule i nagłówku.
Osobny moduł, bo szablony WSPÓLNE dla kilku produktów, a nazwa nie. Wpisana
w szablon na sztywno jechała z nim do każdego obrazu, który go bierze i tak
astroklient przedstawiał się jako astrololo, czyli mówił klientowi wprost, że
trzyma okrojoną wersję czegoś większego, i podawał frazę do wyszukania.
Produkt składający się z tego rdzenia NADPISUJE ten plik swoją nakładką.
"""
from __future__ import annotations
NAZWA = "astrololo"
+136
View File
@@ -0,0 +1,136 @@
"""Most do modułów opcjonalnych — CELOWO nie zna ani jednej ich nazwy.
Ten plik jest w KAŻDYM obrazie, także tam, gdzie nie ma żadnego modułu. Gdyby
wymieniał moduły z nazwy, nazwa nieobecnej funkcji i tak jechałaby do obrazu,
w którym tej funkcji nie ma czyli dokładnie ten ślad, którego całe wydzielanie
ma się pozbyć. Dlatego moduły ODKRYWANE: most przechodzi po podkatalogach
`app/moduly/` i pyta każdy, co wnosi.
Gdy nie ma żadnego, wszystkie odpowiedzi puste, a aplikacja zachowuje się
tak, jakby takiego pojęcia nigdy nie było nie jak aplikacja z wyłączoną
funkcją.
"""
from __future__ import annotations
import importlib
import pkgutil
from typing import Any
def _znalezione() -> list[Any]:
"""Moduły obecne w tym obrazie, w kolejności alfabetycznej katalogów.
Kolejność jest stała, żeby dwa obrazy z tym samym zestawem zachowywały się
identycznie inaczej wynik zależałby od kolejności odczytu katalogu."""
try:
paczka = importlib.import_module("app.moduly")
except ImportError:
return []
znalezione = []
for info in sorted(pkgutil.iter_modules(paczka.__path__), key=lambda i: i.name):
znalezione.append(importlib.import_module(f"app.moduly.{info.name}"))
return znalezione
_MODULY = _znalezione()
DOSTEPNE = bool(_MODULY)
def _zbierz(nazwa: str, domyslne):
"""Wartość atrybutu z każdego modułu, który go ma."""
return [getattr(m, nazwa) for m in _MODULY if hasattr(m, nazwa)] or domyslne
def funkcje() -> tuple[tuple[str, str, str, str], ...]:
"""Pozycje do katalogu funkcji — jako surowe krotki, nie obiekty.
Surowe, bo katalog buduje `features`, a gdyby moduł importował stamtąd klasę,
powstałby cykl: features most moduł features."""
out: list[tuple[str, str, str, str]] = []
for wartosc in _zbierz("FUNKCJA", []):
out.extend(wartosc)
return tuple(out)
def trasy() -> dict[tuple[str, str], str]:
out: dict[tuple[str, str], str] = {}
for wartosc in _zbierz("TRASY", []):
out.update(wartosc)
return out
def zasoby() -> dict[str, Any]:
out: dict[str, Any] = {}
for wartosc in _zbierz("ZASOBY", []):
out.update(wartosc)
return out
def akcje() -> dict[str, str]:
"""Akcje formularza wnoszone przez moduły: nazwa → wymagane uprawnienie."""
out: dict[str, str] = {}
for wartosc in _zbierz("AKCJE", []):
out.update(wartosc)
return out
def pola_formularza():
"""Zależność FastAPI deklarująca dodatkowe pola formularza.
Pola muszą być w SYGNATURZE, żeby FastAPI je odczytało, a sygnatura wspólnego
handlera nie może ich wymieniać. Zależność przenosi deklarację do modułu.
Gdy pola deklaruje więcej niż jeden moduł, trzeba je złożyć a składanie
sygnatur w locie to kod, który psuje się cicho. Póki nikt tego nie potrzebuje,
mówimy o tym wprost przy starcie, zamiast budować machinerię na zapas.
"""
deklarujace = _zbierz("pola", [])
if not deklarujace:
return lambda: {}
if len(deklarujace) > 1:
raise RuntimeError(
"Więcej niż jeden moduł deklaruje pola formularza. Trzeba je złożyć "
"w jedną zależność — patrz komentarz w app/rozszerzenia.py.")
return deklarujace[0]
def kontekst_szablonu(request) -> dict:
"""Co moduły dokładają do kontekstu szablonu. Bez modułów — nic."""
out: dict = {}
for funkcja in _zbierz("katalog_dla", []):
out.update(funkcja(request))
return out
def obsluz(akcja: str, **dane):
"""Wykonanie akcji modułu. None = żaden się do niej nie przyznaje.
Moduł może oddać słownik (trafi do kontekstu szablonu) albo gotową
odpowiedź HTTP na przykład plik do pobrania, który nie jest stroną."""
for funkcja in _zbierz("obsluz", []):
wynik = funkcja(akcja, **dane)
if wynik is not None:
return wynik
return None
def zamontuj(app, **zaleznosci) -> None:
"""Podpięcie tras modułów i przekazanie im tego, czego potrzebują."""
for funkcja in _zbierz("zamontuj", []):
funkcja(app, **zaleznosci)
def _katalogi(nazwa: str) -> list[str]:
import pathlib
return [str(pathlib.Path(m.__file__).parent / nazwa) for m in _MODULY
if (pathlib.Path(m.__file__).parent / nazwa).is_dir()]
def katalog_szablonow() -> list[str]:
"""Katalogi szablonów modułów — nakładane PRZED wspólnymi."""
return _katalogi("templates")
def katalog_zasobow() -> list[str]:
return _katalogi("static")
+2 -2
View File
@@ -1,7 +1,7 @@
"""Ochrona dostępu do aplikacji (LOG-32).
Rdzeniem produktu oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
na wielu ścieżkach (`/significators`, `/interpret` i dalszych), więc BRAK
uwierzytelnienia oznacza, że każdy w sieci może je wypompować bez udziału
jakiegokolwiek modelu językowego. Ten moduł zamyka drogę.
@@ -24,7 +24,7 @@ DZIENNIK AUDYTOWY (PRE-17). Każde żądanie do chronionej ścieżki zostawia wp
kto, kiedy, jaka ścieżka, status, ile rekordów (gdy handler to poda przez
`audit_records`). To jedyny sposób, by zauważyć POWOLNE wypompowywanie bazy przez
osobę uprawnioną pojedyncze zapytanie wygląda niewinnie, suma już nie.
Świadomie NIE logujemy treści żądań, promptów ani rekordów logi to kolejny
Świadomie NIE logujemy treści żądań ani rekordów logi to kolejny
nośnik wycieku; liczby i metadane wystarczą do wykrycia nadużycia.
UWAGA: bez kont ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev). Wtedy przy
+1 -1
View File
@@ -175,7 +175,7 @@ def refresh(dane: dict, *, now: float | None = None) -> str | None:
def cookie_params(secure: bool = True) -> dict:
"""Ustawienia ciasteczka wspólne dla ustawiania i kasowania.
"""Parametry ciasteczka wspólne dla ustawiania i kasowania.
httponly JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
skrypt wynosi sesję. samesite=strict odcina większość ataków polegających
+7 -5
View File
@@ -2,8 +2,10 @@
//
// Raport ma trzy kawałki liczone w różnych miejscach:
// 1. horoskop — liczy serwer przy wysłaniu tego formularza,
// 2. interpretacja natalna — z zakładki Interpretacje (natal.js),
// 3. predykcje okresowe — z zakładki Kalendarz (predictions.js).
// 2. interpretacja natalna — z zakładki Interpretacje,
// 3. predykcje okresowe — z zakładki Kalendarz.
// Kawałki 2 i 3 wnosi moduł opcjonalny. Gdy go nie ma, magazynów nie ma również
// i czytanie ich zwraca pusto — raport składa się wtedy z samego horoskopu.
//
// Punkty 2 i 3 mieszkają w magazynie przeglądarki, więc to tutaj je dokładamy.
// Odczytujemy je przez API tamtych modułów, a nie sięgając wprost do
@@ -20,11 +22,11 @@
}
function readNatal() {
return (window.astrololoNatal && window.astrololoNatal.read()) || null;
return (window.kosmogramNatal && window.kosmogramNatal.read()) || null;
}
function readPredictions() {
return (window.astrololoPredictions && window.astrololoPredictions.read()) || [];
return (window.kosmogramPredictions && window.kosmogramPredictions.read()) || [];
}
function item(ok, label, hint) {
@@ -86,7 +88,7 @@
}
// ── PDF (PRE-24) ────────────────────────────────────────────────────
// Części od AI mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
// Te kawałki mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
// do serwera, który dokłada kosmogram i woła usługę render.
function formData() {
function v(name) {
+3 -3
View File
@@ -16,14 +16,14 @@
(function () {
'use strict';
var KEY = 'astrololo.form.v1';
var KEY = 'kosmogram.form.v1';
// Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć.
// `geoSearch` (nazwa miejsca) nie ma atrybutu name — szukamy po id.
// type: brak = zwykła wartość (.value); 'check' = pojedynczy checkbox (.checked);
// 'multi' = kilka checkboxów o tej samej nazwie (lista zaznaczonych wartości).
// Opcje (stacje, tabele, porównanie domów) są tu, żeby wybór z „Horoskopu"
// wędrował na „Skompiluj" — inaczej podsumowanie liczyłoby się bez nich.
// wędrował na kolejny ekran — inaczej podsumowanie liczyłoby się bez nich.
var FIELDS = [
{ key: 'person', sel: 'input[name=person]' },
{ key: 'date', sel: 'input[name=date]' },
@@ -122,7 +122,7 @@
// 5) geo.js ustawia lat/lon z mapy poza polami formularza — po takiej zmianie
// też chcemy zapamiętać (zdarzenie leci z „Tu i teraz" oraz z mapy)
document.addEventListener('astrololo:coords', function () {
document.addEventListener('kosmogram:coords', function () {
setTimeout(function () { save(collect()); }, 0);
});
})();
+2 -2
View File
@@ -2,7 +2,7 @@
// Wpina się do formularza z polami input[name=lat] / input[name=lon]:
// - wpisanie nazwy/adresu/POI -> /geocode -> lista trafień -> klik ustawia lat/lon,
// - klik na mapie lub przeciągnięcie pineski -> ustawia lat/lon + /reverse pokazuje nazwę,
// - „Tu i teraz" (now.js) emituje event 'astrololo:coords' -> mapa się synchronizuje.
// - „Tu i teraz" (now.js) emituje event 'kosmogram:coords' -> mapa się synchronizuje.
// Bez internetu wyszukiwarka/kafelki nie działają, ale ręczne lat/lon dalej tak.
(function () {
function ready(fn) {
@@ -163,7 +163,7 @@
// zostawała w nim stara, ręcznie wpisana nazwa, więc formularz pokazywał jedno
// miejsce, a współrzędne wskazywały już inne. reverseName pyta o nazwę punktu,
// na który faktycznie ustawiła się mapa.
document.addEventListener('astrololo:coords', function () {
document.addEventListener('kosmogram:coords', function () {
marker.setLatLng([curLat(), curLon()]);
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
reverseName(curLat(), curLon());
+1 -1
View File
@@ -32,7 +32,7 @@ document.addEventListener('DOMContentLoaded', function () {
function (pos) {
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
document.dispatchEvent(new CustomEvent('astrololo:coords')); // zsynchronizuj mapę (geo.js)
document.dispatchEvent(new CustomEvent('kosmogram:coords')); // zsynchronizuj mapę (geo.js)
say('Lokalizacja pobrana ✓');
},
function (err) {
@@ -102,7 +102,7 @@ body.wheel-zoom-open { overflow: hidden; }
.readiness .rdy-missing { color: #e0c060; }
.report-head { margin: 1.5rem 0 .5rem; }
.report-head h2 { margin: 0; font-size: 1.4rem; }
/* Treść od AI bywa długa white-space:pre-wrap zachowuje akapity modelu,
/* Bloki tekstu bywają długie white-space:pre-wrap zachowuje akapity
bez przepisywania ich na HTML. */
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
@@ -34,9 +34,3 @@ td.retro { color: #ff9b6a; font-weight: 600; }
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
.leaflet-control-attribution { font-size: .68rem; }
/* Generator promptu do LLM (LOG-29/30) */
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
background: #12132a; color: var(--ink); border: 1px solid var(--line);
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
@@ -31,9 +31,3 @@ td.retro { color: #ff9b6a; font-weight: 600; }
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
.leaflet-control-attribution { font-size: .68rem; }
/* Generator promptu do LLM (LOG-29/30) */
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
background: #12132a; color: var(--ink); border: 1px solid var(--line);
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
@@ -6,7 +6,7 @@
// rysunku ani żadnej biblioteki.
//
// Podpięte globalnie i osłonięte sprawdzeniem, czy koło w ogóle jest na stronie
// — dzięki temu zadziała też na przyszłej zakładce „Skompiluj", bez zmian tutaj.
// — dzięki temu zadziała wszędzie, gdzie koło się pojawi, bez zmian tutaj.
(function () {
'use strict';
@@ -1,4 +1,4 @@
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i „Skompiluj" (/compile). Jeden plik,
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i raportu zbiorczego (/compile). Jeden plik,
bo dodawaliśmy je już 3× w dwóch formularzach i groziło rozjechaniem (regresja
podsumowania). Wszystkie synchronizowane między zakładkami przez formsync.js. #}
{% if can(request, 'advanced_calc') %}
@@ -1,4 +1,4 @@
{# Tabele wyniku horoskopu — używane przez „Skompiluj" (/compile), żeby
{# Tabele wyniku horoskopu — używane przez raportu zbiorczego (/compile), żeby
podsumowanie pokazywało dokładnie to, co policzono (regresja 2026-07-28:
podsumowanie miało braki). Skopiowane 1:1 z sekcji tabel na „Horoskop" (/).
TODO: przełączyć też chart.html na ten include, żeby oba widoki nie mogły się
+13 -10
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · {% block title %}{% endblock %}</title>
<title>{{ produkt }} · {% block title %}{% endblock %}</title>
{# Arkusz wspólny niesie WYŁĄCZNIE powłokę i logowanie — wszystko, co nazywa
jakąkolwiek funkcję, mieszka w arkuszu tej funkcji i dokłada je `styles`.
Inaczej jeden plik, pobierany też przed zalogowaniem, byłby spisem treści
@@ -20,7 +20,7 @@
<body>
<main>
<header class="topbar">
<h1>astrololo</h1>
<h1>{{ produkt }}</h1>
{# Zakładki WYŁĄCZNIE z uprawnień konta. Nie wyszarzamy niedostępnych —
ich po prostu nie ma, bo sama obecność wyszarzonej pozycji mówi, że
program umie więcej, niż pokazuje (PRE-26). #}
@@ -28,9 +28,12 @@
{% for tab in nav_screens(request) %}
<a href="{{ tab.href }}" class="{{ 'active' if nav_active is defined and nav_active == tab.key else '' }}">{{ tab.label }}</a>
{% endfor %}
{% if can(request, 'admin') %}
<a href="/accounts" class="{{ 'active' if nav_active is defined and nav_active == 'accounts' else '' }}">Konta</a>
{% endif %}
{# Pozycje specjalne z REJESTRU, już przefiltrowane uprawnieniem. Wpisany
tu na sztywno odsyłacz zostawał w produkcie, który tego ekranu nie ma —
razem z nazwą ekranu i martwym adresem. #}
{% for klucz, adres, etykieta in nav_extra(request) %}
<a href="{{ adres }}" class="{{ 'active' if nav_active is defined and nav_active == klucz else '' }}">{{ etykieta }}</a>
{% endfor %}
{# Wyjście na końcu paska i odsunięte: to nie jest zakładka, tylko
czynność — pomylenie go z zakładką kosztuje przypadkowe wylogowania.
Pokazujemy też KTO jest zalogowany, bo bez tego przycisk wyjścia jest
@@ -47,11 +50,11 @@
</nav>
</header>
{% block content %}{% endblock %}
{# Stopka wymieniała warstwy architektury i nazywała całość „widokiem
testowym" — czyli mówiła, że to nie jest gotowy produkt i że za nim stoi
łańcuch usług. Dla konta ograniczonego program ma wyglądać na kompletny,
więc ten opis widzi wyłącznie administrator. #}
{% if can(request, 'admin') %}
{# Stopka wymienia warstwy architektury i nazywa całość „widokiem testowym" —
czyli mówi, że to nie jest gotowy produkt i że za nim stoi łańcuch usług.
Widzi ją wyłącznie konto z uprawnieniem nienadawalnym; dla pozostałych
program ma wyglądać na kompletny. #}
{% if widok_diagnostyczny(request) %}
<footer class="foot">
prezentacja → logika → dane · widok testowy
</footer>
@@ -3,7 +3,7 @@
{% set nav_active = "compile" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-compile.css') }}">
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
{% include "_dodatki_compile_style.html" ignore missing %}
{% endblock %}
{% block head_scripts %}
<script defer src="{{ static('formsync.js') }}"></script>
@@ -12,19 +12,17 @@
{% endblock %}
{% block content %}
{# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja z AI
{# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja wymieniająca
wymieniałaby funkcje, których część kont nie ma — a wymienienie ich w zdaniu
jest takim samym śladem jak przycisk. #}
{% if can(request, 'ai') %}
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
{% else %}
<p class="sub">Składa policzony horoskop w jeden raport. Dane pobiera z pozostałych
zakładek — nie trzeba nic przepisywać.</p>
{% endif %}
{# Jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie. Wariant
warunkowy wymieniałby funkcję z nazwy — a zdanie opisujące funkcję jest takim
samym śladem jak przycisk. #}
<p class="sub">Składa w jeden raport policzony horoskop i wszystkie zapamiętane
części. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
{# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część
kawałków (AI) mieszka w magazynie lokalnym, nie na serwerze. #}
kawałków mieszka w magazynie lokalnym, nie na serwerze. #}
<div id="readiness"></div>
<form method="post" action="/compile">
@@ -137,18 +135,9 @@ zakładek — nie trzeba nic przepisywać.</p>
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
{% include "_result_tables.html" %}
{% if can(request, 'ai') %}
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
<div id="reportNatal"></div>
<div id="reportPredictions"></div>
{% endif %}
{% include "_dodatki_compile_wyniki.html" ignore missing %}
{% endif %}
{% if can(request, 'ai') %}
{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #}
<script src="{{ static('natal.js') }}"></script>
<script src="{{ static('predictions.js') }}"></script>
{% endif %}
{% include "_dodatki_compile_skrypty.html" ignore missing %}
<script src="{{ static('compile.js') }}"></script>
{% endblock %}
@@ -3,7 +3,7 @@
{% set nav_active = "interpret" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-interpret.css') }}">
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
{% include "_dodatki_interpret_style.html" ignore missing %}
{% endblock %}
{% block head_scripts %}
<script defer src="{{ static('formsync.js') }}"></script>
@@ -45,16 +45,12 @@
{# Cały blok generowania — budżet, dostawca, model i wynik — istnieje TYLKO
dla kont z tym uprawnieniem. Nie ukrywamy go stylem: bez uprawnienia nie ma
go w źródle strony, razem z katalogiem modeli, który blok wstrzykuje. #}
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
{% if can(request, 'ai') %}<p class="muted small" id="natalNote"></p>{% endif %}
{% include "_dodatki_interpret_formularz.html" ignore missing %}
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
{% if can(request, 'export') %}<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>{% endif %}
{% if can(request, 'ai') %}
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
{% endif %}
{% include "_dodatki_interpret_akcje.html" ignore missing %}
<span id="geoNote" class="muted small"></span>
</div>
</form>
@@ -111,12 +107,7 @@
<script src="{{ static('now.js') }}"></script>
<script src="{{ static('copy.js') }}"></script>
{# Skrypty obsługujące WYŁĄCZNIE generowanie przez model. Bez uprawnienia nie ma
ich nawet w odsyłaczach — sam adres pliku podpowiadałby, co program potrafi. #}
{% if can(request, 'ai') %}
<script src="{{ static('models.js') }}"></script>
<script src="{{ static('progress.js') }}"></script>
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
<script src="{{ static('natal.js') }}"></script>
{% endif %}
{# Skrypty modułów opcjonalnych. Bez modułu w obrazie gniazdo jest puste —
sam adres pliku podpowiadałby, co program potrafi. #}
{% include "_dodatki_interpret_skrypty.html" ignore missing %}
{% endblock %}
@@ -3,14 +3,14 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · logowanie</title>
<title>{{ produkt }} · logowanie</title>
{# Ekran logowania nie dziedziczy z base.html (nie ma paska zakładek), więc
linkuje arkusz sam. To JEDYNY arkusz publiczny — patrz features.STATIC. #}
<link rel="stylesheet" href="{{ static('base.css') }}">
</head>
<body>
<main class="login-main">
<header class="topbar"><h1>astrololo</h1></header>
<header class="topbar"><h1>{{ produkt }}</h1></header>
{% if request.query_params.get('wylogowano') %}
<p class="done-note">Wylogowano.</p>
@@ -3,7 +3,7 @@
{% set nav_active = "timeline" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-timeline.css') }}">
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
{% include "_dodatki_timeline_style.html" ignore missing %}
{% endblock %}
{% block head_scripts %}
<script defer src="{{ static('formsync.js') }}"></script>
@@ -47,14 +47,11 @@
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
</label>
</div>
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
{% include "_dodatki_timeline_formularz.html" ignore missing %}
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
{% if can(request, 'ai') %}
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
{% endif %}
{% include "_dodatki_timeline_akcje.html" ignore missing %}
<span id="geoNote" class="muted small"></span>
</div>
</form>
@@ -94,7 +91,7 @@
{% endfor %}
{% endif %}
{# Zapamiętane predykcje okresowe (PRE-22) — materiał dla zakładki „Skompiluj" #}
{# Zapamiętane predykcje okresowe (PRE-22) — materiał do raportu zbiorczego #}
<section id="predictionsBox">
<div id="savedPredictions"></div>
<p class="muted small" id="predictionsNote"></p>
@@ -102,10 +99,5 @@
<script src="{{ static('now.js') }}"></script>
<script src="{{ static('copy.js') }}"></script>
{% if can(request, 'ai') %}
<script src="{{ static('models.js') }}"></script>
<script src="{{ static('progress.js') }}"></script>
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
<script src="{{ static('predictions.js') }}"></script>
{% endif %}
{% include "_dodatki_timeline_skrypty.html" ignore missing %}
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
"""Szablon widziany tak, jak zobaczy go przeglądarka — z wstawkami modułów.
Szablony wspólne mają neutralne gniazda (`{% include "_dodatki_*" %}`), a treść
wstawianą w gniazdo trzyma moduł. Testy strukturalne sprawdzają kolejność
skryptów i obecność odsyłaczy, więc muszą patrzeć na CAŁOŚĆ inaczej pilnowałyby
połowy strony i przepuściłyby zamianę kolejności, która psuje działanie.
"""
from __future__ import annotations
import pathlib
import re
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
GNIAZDO = re.compile(r'\{%\s*include\s*"(_dodatki_[\w-]+\.html)"[^%]*%\}')
def zloz(nazwa: str) -> str:
"""Treść szablonu z wstawkami rozwiniętymi W MIEJSCU gniazda."""
tresc = (APP / "templates" / nazwa).read_text(encoding="utf-8")
def wstaw(m: re.Match) -> str:
for katalog in sorted((APP / "moduly").glob("*/templates")):
plik = katalog / m.group(1)
if plik.exists():
return plik.read_text(encoding="utf-8")
return ""
return GNIAZDO.sub(wstaw, tresc)
def zloz_kod() -> str:
"""Cały kod tras aplikacji jako jeden tekst — podstawa plus wszystkie ekrany.
Trasy mieszkają teraz w `app/ekrany/*.py`, a `main.py` jest samym złożeniem.
Testy strukturalne pilnują reguł, które mają obowiązywać W CAŁEJ aplikacji
(każde wejście do baz zostawia ślad w dzienniku", „orb jedzie do logiki"),
więc muszą widzieć całość inaczej sprawdzałyby jeden plik i przepuściły
regułę złamaną w drugim.
"""
czesci = [(APP / "podstawa.py").read_text(encoding="utf-8")]
czesci += [f.read_text(encoding="utf-8")
for f in sorted((APP / "ekrany").glob("*.py"))]
czesci += [f.read_text(encoding="utf-8")
for f in sorted((APP / "moduly").rglob("*.py"))]
return "\n".join(czesci)
@@ -201,5 +201,9 @@ def test_handlers_report_record_counts():
wyniósł 5000 rekordów" — czyli nie wykryłby powolnego wypompowywania bazy."""
import pathlib
main = (pathlib.Path(security.__file__).parent / "main.py").read_text(encoding="utf-8")
# Reguła obowiązuje w CAŁEJ aplikacji, więc patrzymy na wszystkie trasy,
# nie na jeden plik — po podziale main.py jest samym złożeniem.
from tests.skladanie import zloz_kod
main = zloz_kod()
assert main.count("security.audit_records(") >= 3 # sygnifikatory, raport, eksport
@@ -0,0 +1,106 @@
"""Granica modułu generowania jest KATALOGIEM, nie warunkiem w szablonie.
Astrololo zawiera `app/ai/`. Astroklient nie, i to jedyna różnica między nimi
po stronie funkcji astrologicznych. Żeby usunięcie katalogu naprawdę usuwało
funkcję, poza nim nie może zostać ani jedno słowo o niej: ani w kodzie, ani
w szablonie, ani w komentarzu, ani zaprzeczeniem (bez promptów").
Warunek `{% if can(request, 'ai') %}` w szablonie wspólnym tego nie daje. Funkcji
nie WIDAĆ, ale plik dalej leży w obrazie i dalej zawiera jej nazwy `grep` po
kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie.
Ten test jest jedynym miejscem poza modułem, gdzie te słowa wolno wypisać.
"""
import pathlib
import re
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
MODUL = APP / "moduly" / "dodatki"
SLOWA = [
# nazwa funkcji i jej słownictwo
"prompt", "LLM", "model językow", "generowanie tekstu", "sztuczn",
# dostawcy i modele
"anthropic", "openai", "gpt", "claude",
# angielska nazwa akcji (polski „horoskop" to nazwa EKRANU i jest w porządku)
"horoscope",
# nazwy plików modułu — sam odsyłacz podpowiada, co program potrafi
"x-ai.css", "models.js", "progress.js", "natal.js", "predictions.js",
"_prompt_block", "_prompt_result",
]
WZORCE = [(r"(?<![\w-])AI(?![\w-])", "skrót nazwy funkcji")]
def _pliki_poza_modulem():
for f in sorted(APP.rglob("*")):
if not f.is_file() or f.suffix == ".pyc" or "__pycache__" in str(f):
continue
if MODUL in f.parents:
continue
yield f
def test_no_word_about_generation_lives_outside_the_module():
grzechy = []
for f in _pliki_poza_modulem():
try:
tresc = f.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
gdzie = f.relative_to(APP.parent)
for slowo in SLOWA:
if re.search(rf"(?i)(?<![\w-]){re.escape(slowo)}", tresc):
grzechy.append(f"{gdzie}: „{slowo}")
for wzor, powod in WZORCE:
if re.search(wzor, tresc):
grzechy.append(f"{gdzie}: „AI” — {powod}")
assert not grzechy, (
"\nPoza app/ai/ padły słowa opisujące generowanie. Granica ma być "
"katalogiem, więc to, co je nazywa, musi mieszkać w środku:\n "
+ "\n ".join(grzechy))
def test_the_module_is_the_only_thing_that_has_to_go():
"""Kontrola pozytywna: te słowa MAJĄ padać w module.
Bez niej test wyżej przechodziłby także wtedy, gdyby funkcja po prostu
przestała istnieć albo została wydrążona."""
tresc = "\n".join(f.read_text(encoding="utf-8") for f in MODUL.rglob("*")
if f.is_file() and f.suffix in (".py", ".html", ".css", ".js"))
for musi in ("prompt", "horoscope", "x-ai.css", "models.js"):
assert musi in tresc, f"moduł nie zawiera „{musi}” — czy on jeszcze cokolwiek robi?"
def test_removing_the_module_leaves_a_working_application():
"""Katalog ma być ODŁĄCZALNY, nie tylko oddzielony.
Most (`app.rozszerzenia`) musi odpowiadać sensownie, gdy modułu nie ma
inaczej astroklient nie wystartuje, a dowiemy się o tym dopiero przy
budowaniu jego obrazu."""
from app import rozszerzenia
zapamietane = rozszerzenia._MODULY
try:
rozszerzenia._MODULY = []
assert rozszerzenia.funkcje() == ()
assert rozszerzenia.trasy() == {}
assert rozszerzenia.zasoby() == {}
assert rozszerzenia.akcje() == {}
assert rozszerzenia.kontekst_szablonu(None) == {}
assert rozszerzenia.obsluz("prompt", cokolwiek=1) is None
assert rozszerzenia.katalog_szablonow() == []
assert rozszerzenia.katalog_zasobow() == []
assert rozszerzenia.pola_formularza()() == {}
rozszerzenia.zamontuj(None) # ma nie wybuchnąć
finally:
rozszerzenia._MODULY = zapamietane
def test_the_bridge_itself_names_nothing():
"""Most jest w KAŻDYM obrazie, więc on sam nie może niczego wymienić."""
tresc = (APP / "rozszerzenia.py").read_text(encoding="utf-8")
for slowo in SLOWA + ["ai"]:
assert not re.search(rf"(?i)(?<![\w-]){re.escape(slowo)}(?![\w-])", tresc), \
f"most wymienia „{slowo}” — a ma nie wiedzieć, co jest podpięte"
@@ -7,11 +7,14 @@ regresja podsumowania).
"""
import pathlib
from tests.skladanie import zloz_kod
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje obu formularzy
MAIN = (APP / "main.py").read_text(encoding="utf-8")
# Trasy mieszkają w app/ekrany/* — patrz tests/skladanie.py.
MAIN = zloz_kod()
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
SYNC = (APP / "static" / "formsync.js").read_text(encoding="utf-8")
COMPILE_JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
+12 -6
View File
@@ -11,12 +11,18 @@ import pathlib
from starlette.testclient import TestClient
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TPL = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
from tests.skladanie import zloz, zloz_kod
# Szablon z rozwiniętymi wstawkami modułów — patrz tests/skladanie.py.
TPL = zloz("compile.html")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
NATAL_JS = (APP / "static" / "natal.js").read_text(encoding="utf-8")
INTERPRET = (APP / "templates" / "interpret.html").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
# Skrypty generowania mieszkają w module `app/ai/` — katalogu, którego
# w obrazie bez tej funkcji po prostu nie ma.
NATAL_JS = (APP / "moduly" / "dodatki" / "static" / "natal.js").read_text(encoding="utf-8")
INTERPRET = zloz("interpret.html")
# Trasy mieszkają w app/ekrany/* — patrz tests/skladanie.py.
MAIN = zloz_kod()
def _client():
@@ -131,8 +137,8 @@ def _pdf_sample_chart() -> dict:
def test_reads_stores_through_their_api_not_raw_storage():
"""Format danych ma jednego właściciela — moduł, który je zapisuje.
Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie."""
assert "window.astrololoNatal" in JS
assert "window.astrololoPredictions" in JS
assert "window.kosmogramNatal" in JS
assert "window.kosmogramPredictions" in JS
# chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu)
assert "localStorage.getItem" not in JS
assert "localStorage.setItem" not in JS
@@ -0,0 +1,113 @@
"""Produkt składa się z modułów — i węższe złożenie naprawdę działa.
Ekrany mieszkają w `app/ekrany/*.py`, moduły opcjonalne w `app/moduly/*`, a
`main.py` jest samym złożeniem. Sens tego podziału jest jeden: z tego samego
kodu ma dać się zbudować WĘŻSZY produkt, w którym pozostałych ekranów nie ma
nie wyłączone, tylko ich nie ma.
Test buduje takie złożenie naprawdę: kopiuje drzewo, usuwa moduły, uruchamia
aplikację w osobnym procesie i sprawdza, co się stało. Robi to w podprocesie,
bo importy Pythona zapamiętywane w procesie sprawdzanie aplikacji bez
modułu" w tym samym procesie, w którym moduł już się zaimportował, dawałoby
wynik fałszywie pozytywny.
Czego ten test NIE sprawdza: czy w drzewie nie ma wzmianek o nieobecnych
ekranach. To ma sens dopiero wobec KONKRETNEGO produktu (wiadomo wtedy, których
ekranów nie ma) i tam jest sprawdzane patrz zapory słownikowe.
"""
import json
import pathlib
import shutil
import subprocess
import sys
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
# Złożenie węższe: same funkcje astrologiczne, bez raportu zbiorczego,
# bez administracji, bez generowania i bez wynoszenia treści na zewnątrz.
EKRANY_WASKIE = ["horoskop", "interpretacje", "kalendarz", "logowanie",
"lokalizacja", "sygnifikatory", "synastria"]
USUWANE = ["ekrany/skompiluj.py", "ekrany/ustawienia.py", "ekrany/konta.py",
"ekrany/pliki.py", "moduly/dodatki", "moduly/eksport"]
SONDA = '''
import json, os, tempfile
os.environ.update(ACCOUNTS_FILE=tempfile.mkdtemp()+"/a.json", APP_USER="szef",
APP_PASSWORD="t", RATE_LIMIT_PER_MIN="0",
SESSION_SECRET="t"*64, COOKIES_INSECURE="1")
from starlette.testclient import TestClient
from app.main import app
from app import security, session, features
c = TestClient(app, follow_redirects=False)
H = {"Cookie": f"{session.COOKIE}={security.issue_session('szef')}"}
wynik = {
"ekrany": [f.label for f in features.SCREENS],
"rozszerzenia": [f.label for f in features.EXTRAS],
"dziala": {a: c.get(a, headers=H).status_code
for a in ("/", "/interpret", "/timeline", "/synastry", "/significators")},
"nie_ma": {a: c.get(a, headers=H).status_code
for a in ("/compile", "/settings", "/accounts", "/files")},
# Liczymy odsyłacze WEWNĄTRZ paska nawigacji. Zliczanie wszystkich na stronie
# łapało też przypis OSM z wyszukiwarki miejsca — czyli test mierzyłby
# co innego, niż nazwa mu każe.
"nawigacja": (lambda h: h.split("<nav>")[1].split("</nav>")[0].count('<a href="'))(
c.get("/", headers=H).text),
}
print("WYNIK:" + json.dumps(wynik))
'''
@pytest.fixture(scope="module")
def waskie(tmp_path_factory):
korzen = tmp_path_factory.mktemp("waskie")
shutil.copytree(APP, korzen / "app",
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
for sciezka in USUWANE:
cel = korzen / "app" / sciezka
shutil.rmtree(cel) if cel.is_dir() else cel.unlink()
(korzen / "app" / "main.py").write_text(
"from app import rozszerzenia\n"
"from app.podstawa import _build_utc, _logic_error, app, logic, templates\n"
f"from app.ekrany import {', '.join(EKRANY_WASKIE)} # noqa: F401\n"
"rozszerzenia.zamontuj(app, logic=logic, templates=templates,\n"
" buduj_utc=_build_utc, blad=_logic_error)\n",
encoding="utf-8")
wynik = subprocess.run([sys.executable, "-c", SONDA], cwd=korzen,
capture_output=True, text=True,
env={"PYTHONPATH": str(korzen), "PATH": "/usr/bin:/bin"})
linia = next((l for l in wynik.stdout.splitlines() if l.startswith("WYNIK:")), None)
assert linia, f"sonda nie wystartowała:\n{wynik.stdout[-2000:]}\n{wynik.stderr[-2000:]}"
return json.loads(linia[len("WYNIK:"):])
def test_the_narrow_build_starts_at_all(waskie):
"""Najpierw to: aplikacja bez połowy modułów ma w ogóle wstać."""
assert waskie["dziala"]["/"] == 200
def test_every_screen_it_kept_works(waskie):
assert all(kod == 200 for kod in waskie["dziala"].values()), waskie["dziala"]
def test_every_screen_it_dropped_is_gone_not_merely_closed(waskie):
"""404, nie 403 i nie 500: ekran ma NIE ISTNIEĆ, a nie być odmówiony."""
assert all(kod == 404 for kod in waskie["nie_ma"].values()), waskie["nie_ma"]
def test_the_catalogue_describes_this_build_not_some_other(waskie):
"""Katalog funkcji powstaje ze zgłoszeń, więc opisuje ten obraz.
Wcześniej lista była wpisana na sztywno i obraz bez części ekranów i tak
niósł ich nazwy czyli spis funkcji, których nie ma jak włączyć."""
assert set(waskie["ekrany"]) == {"Horoskop", "Interpretacje", "Kalendarz",
"Synastria", "Sygnifikatory"}
assert "Eksport plików" not in waskie["rozszerzenia"]
assert "Generowanie tekstu przez model" not in waskie["rozszerzenia"]
def test_navigation_has_no_dead_links(waskie):
"""Pozycje administracyjne też idą z rejestru — wpisany na sztywno odsyłacz
do ekranu kont zostawał w produkcie, który tego ekranu nie ma."""
assert waskie["nawigacja"] == len(waskie["ekrany"])
+4 -1
View File
@@ -8,12 +8,15 @@ import pathlib
import pytest
from tests.skladanie import zloz_kod
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TEMPLATES = APP / "templates"
SYNC_JS = (APP / "static" / "formsync.js").read_text(encoding="utf-8")
GEO_JS = (APP / "static" / "geo.js").read_text(encoding="utf-8")
BASE_HTML = (TEMPLATES / "base.html").read_text(encoding="utf-8")
MAIN_PY = (APP / "main.py").read_text(encoding="utf-8")
# Trasy mieszkają w app/ekrany/* — patrz tests/skladanie.py.
MAIN_PY = zloz_kod()
WITH_FORM = ("chart.html", "interpret.html", "timeline.html")
@@ -7,12 +7,15 @@ import pathlib
import pytest
from tests.skladanie import zloz_kod
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje
TABLES = (APP / "templates" / "_result_tables.html").read_text(encoding="utf-8") # wspólne tabele
MAIN = (APP / "main.py").read_text(encoding="utf-8")
# Trasy mieszkają w app/ekrany/* — patrz tests/skladanie.py.
MAIN = zloz_kod()
from app.house_systems import HOUSE_SYSTEMS, LIMITED, label as house_label # noqa: E402
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
@@ -192,7 +195,8 @@ def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
# pilnuje osobny test pokrycia.
CSS = "\n".join(f.read_text(encoding="utf-8")
for f in sorted((APP / "static").glob("*.css")))
for f in sorted([*(APP / "static").glob("*.css"),
*(APP / "moduly" / "dodatki" / "static").glob("*.css")]))
def test_comparison_table_scrolls_instead_of_widening_the_page():
@@ -7,9 +7,13 @@ nie może puchnąć od powtórzonych prób na tym samym okresie.
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
JS = (APP / "static" / "predictions.js").read_text(encoding="utf-8")
PROGRESS = (APP / "static" / "progress.js").read_text(encoding="utf-8")
TIMELINE = (APP / "templates" / "timeline.html").read_text(encoding="utf-8")
# Skrypty generowania mieszkają w module `app/ai/` — katalogu, którego
# w obrazie bez tej funkcji po prostu nie ma.
JS = (APP / "moduly" / "dodatki" / "static" / "predictions.js").read_text(encoding="utf-8")
PROGRESS = (APP / "moduly" / "dodatki" / "static" / "progress.js").read_text(encoding="utf-8")
from tests.skladanie import zloz
TIMELINE = zloz("timeline.html")
# ─────────────────────────────── podpięcie ───────────────────────────────
@@ -78,7 +82,7 @@ def test_corrupt_storage_does_not_break_the_page():
def test_storage_is_exposed_for_the_compile_tab():
"""Zakładka „Skompiluj" (PRE-23) ma czytać to samo miejsce — jedno źródło prawdy."""
assert "window.astrololoPredictions" in JS
assert "window.kosmogramPredictions" in JS
def test_does_nothing_on_other_tabs():
@@ -8,11 +8,14 @@ import pathlib
import pytest
from tests.skladanie import zloz_kod
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CLIENT = APP / "clients" / "render_client.py"
SRC = CLIENT.read_text(encoding="utf-8")
WHEEL = (APP / "chartwheel.py").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
# Trasy mieszkają w app/ekrany/* — patrz tests/skladanie.py.
MAIN = zloz_kod()
COMPILE_JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
@@ -4,10 +4,14 @@ import pathlib
from openpyxl import load_workbook
from app.report_export import HEADERS, report_to_xlsx
# Eksport jest modułem odłączalnym — mieszka w app/moduly/eksport/.
from app.moduly.eksport.arkusz import HEADERS, report_to_xlsx
from tests.skladanie import zloz_kod
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
MAIN = (APP / "main.py").read_text(encoding="utf-8")
# Trasy mieszkają w app/ekrany/* — patrz tests/skladanie.py.
MAIN = zloz_kod()
INTERPRET = (APP / "templates" / "interpret.html").read_text(encoding="utf-8")
@@ -70,7 +74,46 @@ def test_interpret_has_export_button():
def test_handler_returns_xlsx_download():
assert 'if action == "export":' in MAIN
# Eksport jest modułem odłączalnym, więc akcję zgłasza on sam — ekran nie
# wymienia jej z nazwy. Pilnujemy zgłoszenia, nie dawnego `if` w handlerze.
assert '"export": "export"' in MAIN
assert "report_to_xlsx" in MAIN
assert "spreadsheetml.sheet" in MAIN # media type .xlsx
assert 'filename="interpretacje.xlsx"' in MAIN
def test_the_export_action_really_returns_a_spreadsheet(tmp_path, monkeypatch):
"""Ścieżka szczęśliwa, nie tylko odmowa.
Testy uprawnień sprawdzały dotąd, że konto BEZ uprawnienia arkusza nie
dostanie. Po wydzieleniu eksportu do modułu okazało się, że nikt nie
sprawdza, czy konto Z uprawnieniem dostaje cokolwiek a moduł dostawał
zależności dopiero z wywołania montującego i brak jednej z nich wyszedłby
dopiero przy pierwszym kliknięciu.
"""
import os
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
monkeypatch.setenv("APP_USER", "szef")
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
monkeypatch.delenv("APP_USERS", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
monkeypatch.setenv("COOKIES_INSECURE", "1")
os.environ.setdefault("SESSION_SECRET", "t" * 64)
from starlette.testclient import TestClient
from app import security, session
from app.main import app, logic
monkeypatch.setattr(logic, "report", lambda **kw: {"objects": [], "engine": "test"})
c = TestClient(app, follow_redirects=False)
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('szef')}"}
r = c.post("/interpret", headers=naglowki,
data={"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94", "action": "export"})
assert r.status_code == 200
assert "spreadsheetml" in r.headers.get("content-type", "")
assert r.headers.get("content-disposition", "").endswith('"interpretacje.xlsx"')
assert r.content[:2] == b"PK", "arkusz .xlsx to archiwum ZIP — to nim nie jest"
@@ -1,10 +1,13 @@
"""Ekran ustawień: bazy na udziale (PRE-09)."""
import pathlib
from tests.skladanie import zloz_kod
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TPL = (APP / "templates" / "settings.html").read_text(encoding="utf-8")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
# Trasy mieszkają w app/ekrany/* — patrz tests/skladanie.py.
MAIN = zloz_kod()
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
@@ -46,14 +46,14 @@ def _geo_source() -> str:
def _handler_body() -> str:
"""Ciało handlera zdarzenia astrololo:coords (wysyłanego przez „Tu i teraz").
"""Ciało handlera zdarzenia kosmogram:coords (wysyłanego przez „Tu i teraz").
Szukamy KONKRETNIE `addEventListener`, nie samej nazwy zdarzenia ta pada
także w komentarzu nagłówkowym pliku i wtedy wycięlibyśmy komentarz zamiast
kodu (test przechodziłby albo padał z niewłaściwego powodu).
"""
src = _geo_source()
start = src.index("addEventListener('astrololo:coords'")
start = src.index("addEventListener('kosmogram:coords'")
end = src.index("});", start)
return src[start:end]
@@ -63,7 +63,7 @@ def test_now_button_handler_refreshes_place_name():
a w polu tekstowym zostawała stara nazwa formularz pokazywał jedno miejsce,
a liczył dla innego."""
assert "reverseName(" in _handler_body(), (
"handler astrololo:coords nie odświeża nazwy miejsca (reverseName)"
"handler kosmogram:coords nie odświeża nazwy miejsca (reverseName)"
)
+4 -1
View File
@@ -1,10 +1,13 @@
"""Synastria — technika relacyjna (PRE-04)."""
import pathlib
from tests.skladanie import zloz_kod
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TPL = (APP / "templates" / "synastry.html").read_text(encoding="utf-8")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
# Trasy mieszkają w app/ekrany/* — patrz tests/skladanie.py.
MAIN = zloz_kod()
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")

Some files were not shown because too many files have changed in this diff Show More