Compare commits

..

18 Commits

Author SHA1 Message Date
gitea c1f5bea9f7 astrololo: ekrany i eksport jako moduły, katalog z rejestracji (3/5)
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
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 13:50:34 +02:00
gitea d094a145b1 astrololo: generowanie tekstu jako moduł odłączalny (2/5)
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4m44s
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 / Build obrazu silnika B (swisseph) (pull_request) Successful in 6s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m43s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
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 12:23:08 +02:00
gitea 320a0ab24e PRE-27: pełne ukrycie niedostępnych funkcji (paranoja)
build-render / build (push) Failing after 8s
build-swisseph / build (push) Successful in 9s
build / build (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 5s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 4s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 4s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Failing after 4s
Ukrywanie jest teraz nadrzędne wobec wygody i czytelności komunikatów.
Persona: konto z uprawnieniami files + files_input, zatrudnione wyłącznie do
wgrywania plików. Nie ma się dowiedzieć, po co je wgrywa ani co program będzie
robił — bo to rozgada.

Audyt sześciu kanałów wycieku (statyki, HTML, sondowanie HTTP, ekran plików,
odpowiedzi JSON i błędy, pozostałe warstwy) potwierdził 26 wycieków, każdy
odtworzony uruchomionym kodem i zweryfikowany adwersarialnie. Ani jeden nie był
przyciskiem.

ZASÓB JEST CZĘŚCIĄ FUNKCJI
/static/ omijało CAŁĄ bramkę (PUBLIC_PREFIXES), więc każdy skrypt i arkusz
pobierał ktokolwiek, także niezalogowany, pod zgadywalnym adresem — a ich treść
wymienia ekrany, dostawców modeli i przeznaczenie plików. Ruch ten nie trafiał
przy tym ani do dziennika, ani pod limit żądań, więc wyciek był niewidoczny.
Zasoby idą teraz trasą z bramką; każdy ma w features.STATIC uprawnienie swojego
ekranu. Publiczny został jeden base.css, bo potrzebuje go ekran logowania.

KOMENTARZ NIE JEDZIE NA DRUT
Komentarze w CSS/JS opisywały funkcje pełnymi zdaniami po polsku — łącznie
z „Wstrzymane widzi tylko administrator", czyli i mechanizmem kwarantanny,
i istnieniem konta o wyższych uprawnieniach. _asset_body() usuwa je przy
serwowaniu; w repozytorium zostają.

styles.css rozbity na base.css + arkusz na ekran + x-ai.css. Jeden plik z
wszystkimi selektorami był spisem treści programu. Podział zrobiony
mechanicznie, z osobnym sprawdzeniem, że żaden ekran nie stracił reguły.

base.html ładował skrypty kosmogramu na KAŻDEJ stronie — konto mające wyłącznie
Pliki pobierało je przy wejściu na swój jedyny ekran, razem ze wzmianką
o „przyszłej zakładce". Teraz dokłada je ekran, który ich używa.

RÓŻNICA JEST INFORMACJĄ
Komunikat po wgraniu pliku różnił się zależnie od wyniku walidacji — czyli był
wyrocznią do odgadywania reguł, które ma znać tylko administrator — i mówił
wprost, że plik „musi zatwierdzić administrator". Teraz jest jeden, ten sam.

_logic_error wypisywał na ekran nazwę trasy, nazwę podsystemu, nazwę gałęzi
rozwojowej i wewnętrzny host:port. Jedno zdanie dla wszystkich awarii, szczegóły
do dziennika. Odsiew w jednym punkcie, nie w siedemnastu wywołaniach.

Ponadto: stopka nie ogłasza architektury, /health nie nazywa warstwy, konto bez
ekranów dostaje 404 zamiast tłumaczenia, ekran plików mówi o plikach zamiast
o „bazach interpretacyjnych", klasy .house-warning i .account-card przemianowane
na neutralne, a logic/data/render/engine-swisseph nie wystawiają już /docs ani
/openapi.json i nie publikują portów na hoście.

ZAPORA SŁOWNIKOWA
test_slownik_zakazany.py nie sprawdza miejsc, tylko przechodzi wszystko, co dane
konto może pobrać, i szuka słów, które nie mają prawa paść (87 pozycji dla tej
persony). Nazwy funkcji, adresy ekranów i nazwy zasobów biorą się wprost
z katalogu, więc nowa funkcja obejmuje się sama. Kontrola pozytywna pilnuje, żeby
test nie przechodził dlatego, że program jest pusty.

Sprawdzone: zapora puszczona na treść sprzed poprawek daje 16 trafień na samym
styles.css i łapie każdy ze zneutralizowanych komunikatów. 358 testów zielonych,
ekrany obejrzane w przeglądarce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:54:03 +02:00
gitea 1d0ff2f72e PRE-27: generowanie tekstu przez model znika bez uprawnienia
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 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
build / build (push) Successful in 1m39s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m45s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 6s
Wymaganie było mocniejsze niż schowanie przycisku: po niedostępnej funkcji nie
może zostać śladu w źródle strony. Największy wyciek nie był przyciskiem —
_prompt_block wstrzykiwał w stronę CAŁY katalog modeli jako JSON (dostawcy,
nazwy modeli, rozmiary okien kontekstu), na każdym ekranie z generowaniem,
niezależnie od uprawnień konta.

Druga dziura była głębsza: handlery nie sprawdzały nic. Trasa /interpret musi
być dostępna dla konta z Interpretacjami, więc granica przebiega WEWNĄTRZ niej,
po polu `action` — spreparowany formularz z action=prompt generował tekst, a
action=export pobierał arkusz, mimo że szablon chował oba przyciski.

Akcja bez uprawnienia wraca do akcji domyślnej ekranu zamiast dawać błąd:
komunikat „brak uprawnień do generowania" sam w sobie mówiłby, że taka funkcja
istnieje.

Ślady wycięte także tam, gdzie nie były kontrolką: znaczniki natalNote/
reportNatal, pliki models.js/progress.js/natal.js/predictions.js oraz podtytuł
ekranu Skompiluj, który wymieniał interpretację od AI z nazwy.

12 testów; 9 z nich pada na kodzie sprzed poprawki (sprawdzone przez cofnięcie
zmian w app/). Kontrola pozytywna pilnuje, żeby nie przechodziły dlatego, że
generowanie jest zepsute dla wszystkich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:10:16 +02:00
gitea f0d07ee8c3 feat(bezpieczeństwo): sesje logowania zamiast HTTP Basic (LOG-34)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m17s
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 / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
build / build (push) Successful in 7s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m18s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Basic nie miał wylogowania i nie dało się tego obejść: przeglądarka zapamiętuje
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
Poprzednia próba (LOG-32) opierała się na nakłonieniu przeglądarki, żeby porzuciła
zapamiętane dane — zachowaniu powszechnym, ale nigdzie nie zapisanym. Teraz to
serwer decyduje, czy dana przeglądarka jest w środku, i może to cofnąć.

TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
  1. wylogowanie = skasowanie ciasteczka. Natychmiastowe, bez magazynu.
  2. zmiana hasła albo skasowanie konta = odcisk poświadczenia wpisany
     w ciasteczko przestaje pasować. Dzieje się SAMO, bez pamiętania o tym.
     Bez tego odebranie komuś dostępu nie odbierałoby dostępu aż do wygaśnięcia.
  3. „zamknij sesje" z ekranu kont = licznik pokolenia. Jedyny wymagający zapisu,
     więc jedyny opcjonalny: gdy licznika nie ma, poziomy 1 i 2 nadal działają.

KONTO ADMINISTRACYJNE ODSEPAROWANE. Sprawdzane pierwsze i BEZ DOTYKANIA pliku
kont, co daje dwie rzeczy naraz: konto z pliku o tym samym loginie nie przesłoni
administratora, a administrator zaloguje się także wtedy, gdy plik jest uszkodzony
— czyli w jedynej sytuacji, w której ktoś MUSI wejść, żeby to naprawić. Trzymanie
jego stanu w tym samym pliku dawałoby zakleszczenie: nie da się naprawić, bo nie
da się wejść. Jego odpowiednikiem „wyloguj zewsząd" jest zmiana APP_PASSWORD.

KLUCZ WYMAGANY, FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie
odróżniłaby ważnej sesji od podrobionej, więc nie wstaje — i lepiej przy starcie
niż przy pierwszym logowaniu człowieka. Losowanie klucza byłoby wygodne, ale
wylogowywałoby wszystkich przy każdym restarcie poda: wygląda jak awaria i uczy
ludzi ignorować ekran logowania.

Ciasteczko HttpOnly (jeden wstrzyknięty skrypt inaczej wynosi sesję) i
SameSite=Strict (obca strona nie zadziała w imieniu zalogowanego). Wylogowanie
POST-em, nie odsyłaczem: pod adresem GET wystarczyłby obrazek na obcej stronie.
Adres powrotu po zalogowaniu przechodzi przez sito — bez tego `?dokad=https://obcy`
zamieniłby nasz ekran logowania w narzędzie do wyłudzania haseł.

Kres bezczynności 8 h i twardy 30 dni. Znacznik aktywności odświeżany z progiem,
inaczej Set-Cookie leciałby przy każdym obrazku i arkuszu stylów.

Zdarzenia logowania w dzienniku (PRE-17). Nieudane próby są tam ważniejsze od
udanych: pojedyncza nic nie znaczy, ale seria pod jednym adresem to jedyny
widoczny ślad zgadywania haseł. Test pilnuje, że hasło tam nie trafia.

Sprawdzone w przeglądarce: po zalogowaniu ciasteczko jest NIEWIDOCZNE dla
JavaScriptu, a po wylogowaniu wejście na chronioną stronę ląduje na ekranie
logowania — czyli dokładnie to, czego Basic nie potrafił.

Testy: 21 na rdzeń podpisywania (w tym podrabianie ładunku, podpisu i klucza,
ciasteczko z przyszłości, śmieci na wejściu), reszta przepisana z Basic na sesje.
Prezentacja 338 zielonych.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:18:15 +02:00
gitea 71bb3b9c0a feat(bezpieczeństwo): przycisk wylogowania (LOG-32)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m17s
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 / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
build / build (push) Successful in 1m18s
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 / Build obrazu silnika B (swisseph) (push) Successful in 5s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Aplikacja nie miała jak z siebie wyjść: raz podane hasło działało do zamknięcia
przeglądarki, a na wspólnym komputerze nie było sposobu, żeby oddać ekran komuś
innemu.

HTTP BASIC NIE MA PRAWDZIWEGO WYLOGOWANIA i to jest sedno tej zmiany. Nie ma
sesji do skasowania: przeglądarka zapamiętuje login i hasło, po czym dosyła je
SAMA przy każdym żądaniu. Serwer nie ma czego zapomnieć — kolejne kliknięcie
przyszłoby z kompletem poświadczeń i weszłoby z powrotem.

Działa natomiast doprowadzenie do tego, żeby to PRZEGLĄDARKA porzuciła zapamiętane
dane, i robimy to dwutorowo, bo ani jedno, ani drugie osobno nie wystarcza:

  1. /wyloguj odpowiada ZAWSZE 401 z nagłówkiem WWW-Authenticate, co wymusza
     ponowne pytanie o hasło. Działa bez JavaScriptu, ale samo w sobie zostawia
     stare dane w pamięci przeglądarki: po anulowaniu okienka wystarczyłoby wejść
     na dowolny adres, żeby wrócić do środka.
  2. wyloguj.js wysyła żądanie z CELOWO błędnymi danymi, którym przeglądarka
     nadpisuje swój wpis. To jest część, która faktycznie czyści pamięć — ale
     opiera się na zachowaniu powszechnym, a nie zapisanym w standardzie, więc
     nie może być jedynym mechanizmem.

Strona wylogowania mówi wprost, że pewnym sposobem w KAŻDEJ przeglądarce jest
zamknięcie okna. Nie obiecujemy więcej, niż Basic potrafi — obietnica bez pokrycia
byłaby tu gorsza od braku przycisku, bo dawałaby złudzenie, że ekran jest oddany.

Trasa stoi POZA bramką logowania, celowo: inaczej dostałaby 200 od zalogowanej
sesji i nie miałaby jak odpowiedzieć 401. Odpowiada też bez logowania — inaczej
wyjście wymagałoby bycia w środku, co jest błędnym kołem. Nagłówki zakazują
zapamiętania strony, bo oddana z pamięci podręcznej nie dotarłaby do serwera
i okienko w ogóle by się nie pojawiło.

Obok wyjścia pokazujemy, KTO jest zalogowany: bez tego przycisk jest w połowie
bezużyteczny, bo na wspólnym komputerze nie wiadomo, kogo się wylogowuje. Przy
wyłączonym logowaniu nie ma ani jednego, ani drugiego — przycisk sugerowałby
ochronę, której nie ma.

Sprawdzone w przeglądarce: oba żądania wychodzą (to z błędnymi danymi i samo
przejście), oba wracają 401, strona renderuje się poprawnie. Samego unieważnienia
pamięci poświadczeń NIE dało się tu potwierdzić — wymaga okienka systemowego,
którego automat nie obsłuży.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:52:04 +02:00
gitea ee3c515d59 fix(testy): awaria magazynu kont wymuszana podmianą, nie prawami pliku
build / build (push) Successful in 7s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m29s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Dwa testy z PR #71 przechodziły lokalnie i padały w CI. Przyczyna nie miała nic
wspólnego z badaną rzeczą: wymuszały awarię przez chmod 000 i chmod 555, a CI
działa jako ROOT — root omija bity uprawnień w Linuksie, więc odczyt i zapis się
udawały i asercje leciały na komunikat, którego nie było.

Test zależny od tego, KTO go uruchamia, jest gorszy niż jego brak: daje fałszywe
poczucie pokrycia i zapala się w miejscu niezwiązanym z tym, co sprawdza.

Teraz awarię wymuszamy podmianą dokładnie tego punktu, w którym system plików
mówi „nie": odczytu pliku kont (osłona przepuszcza wszystko inne, żeby szablony
nadal się wczytywały) oraz mkstemp przy zapisie. To drugie jest celowe — mkstemp
wywala się PIERWSZY przy katalogu tylko do odczytu, jeszcze zanim dojdzie do
zapisu i podmiany, i właśnie tego dotyczyła naprawiana poprawka.

Sprawdzian na PRAWDZIWYCH prawach zostaje, ale z pominięciem przy uruchomieniu
z roota — jako kontrola, że podmiana odpowiada rzeczywistości, a nie tylko sama
sobie. W CI się nie wykona i to jest w porządku: znaczyłby tam tyle co nic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:38:14 +00:00
gitea ac8a0e9fc6 fix(dane): trzy błędy styku rejestru plików z resztą warstwy (DAN-27)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m29s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Failing after 4m53s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m27s
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 6s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m28s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 4m52s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m24s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Po wdrożeniu DAN-27 przestało działać wyszukiwanie (500 z /search) i ekran
Ustawienia (502 z /bases). Ekran „Pliki" działał, co dobrze pokazuje, gdzie leżał
problem: nie w rejestrze, tylko w JEGO STYKU z kodem, który zastąpił.

1. NameError przy KAŻDYM wyszukiwaniu. Przepisując `_enabled_files` pod rejestr
   usunąłem lokalny `from app import bases`, a modułowego w tym pliku nigdy nie
   było. Wołanie bases.disabled_entries() wywracało się natychmiast.

2. KeyError na /bases. Rejestr oddawał `in_use`, a endpoint liczy `b["enabled"]` —
   tak samo warstwa logiczna i szablon Ustawień (DAN-15/PRE-09). Rejestr wszedł
   w miejsce starej listy baz, więc musi mówić jej językiem; oddaje teraz oba
   pola o tej samej wartości.

3. Cache podawany jako baza. `_scan` filtrował tylko nazwę PLIKU, więc zawartość
   `.cache` wchodziła do rejestru (pliki w środku nie zaczynają się od kropki),
   a przy pierwszym uruchomieniu była jeszcze przyjmowana jako aktywna. Teraz
   pomijamy wszystko, co leży w ukrytym KATALOGU. Ten wyszedł dopiero z nowych
   testów — nie wiedziałem o nim.

DLACZEGO TESTY TEGO NIE ZŁAPAŁY. test_files.py sprawdza rejestr w IZOLACJI i był
zielony, podczas gdy produkcja leżała. Groźne w takiej podmianie nie jest to, co
nowy moduł robi w środku, tylko czy mówi tym samym językiem, co jego odbiorcy.
Doszedł więc test_rejestr_integracja.py: wyszukiwanie przez dostawcę (obie gałęzie,
także ta z DISABLED_BASES), kontrakt pól listy baz, endpoint /bases przez trasę
oraz odstawienie bazy widziane JEDNOCZEŚNIE w wyszukiwaniu i w liczniku.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:26:42 +02:00
gitea b838cf4723 fix(dane): bazy zastane na udziale zostają w użyciu po przejściu na rejestr
build / build (push) Successful in 7s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m35s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 4m52s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Bez tego wdrożenie DAN-27 WYŁĄCZYŁOBY WYSZUKIWANIE. Dotąd bazy działały domyślnie
(wyłączało się je jawnie przez DISABLED_BASES). Po przejściu na rejestr plik bez
wpisu w stanie dostaje `ready`, czyli NIE w użyciu — a stan po wdrożeniu jest
pusty. Efekt: żadna baza nie jest aktywna i program nagle niczego nie znajduje.

Ta cicha zmiana zachowania byłaby gorsza od awarii, bo wygląda jak pusta baza,
a nie jak zepsuty deploy — i szukałoby się jej w warstwie danych albo w indeksie.

Teraz brak PLIKU stanu oznacza pierwsze uruchomienie i bazy zastane są przyjmowane
jako aktywne. Pusty słownik przy ISTNIEJĄCYM pliku to co innego: ktoś świadomie
wszystko odstawił, więc nie wskrzeszamy — osobny test tego pilnuje.

Rozróżnienie jest celowe i też pod testem: `ready` dotyczy plików WGRANYCH przez
ekran (te ktoś musi świadomie włączyć), a nie zastanych przy przejściu na rejestr.
Inaczej nowa baza wchodziłaby do wyników sama, bez niczyjej decyzji.

Przyjęcie działa też na udziale tylko do odczytu: zapis stanu wtedy nie przechodzi,
więc powtórzy się przy każdym uruchomieniu — zachowanie to samo, koszt żaden.

Dwa testy opisujące STARE zachowanie zostały poprawione, bo to one były błędne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:44:30 +00:00
gitea e2b50b284f fix(konta): awaria magazynu tłumaczy się zamiast dawać gołe 500
Testy / Testy warstwy logicznej (silnik) (push) Successful in 26m56s
build / build (push) Successful in 9s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 4m54s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Ekran „Konta" wywalał się na produkcji błędem 500 bez słowa wyjaśnienia.
Odtworzone lokalnie: `_read()` łapał wyłącznie brak pliku i zły JSON, więc każdy
inny błąd systemu plików — a na udziale NFS to głównie prawa — leciał na wierzch
jako nieobsłużony wyjątek.

To jest szczególnie zły sposób na awarię AKURAT TUTAJ: ekran kont jest jedynym
miejscem, z którego administrator może taki problem naprawić, a gołe 500 nie mówi
mu ani co, ani gdzie.

Teraz każdy błąd magazynu ma twarz: osobny wyjątek AccountsUnavailable niosący
ŚCIEŻKĘ i powód z systemu operacyjnego, plus podpowiedź najczęstszej przyczyny
(prawa katalogu na udziale albo wolumen zamontowany tylko do odczytu). Strona
renderuje się normalnie z tym komunikatem u góry.

Objęte są wszystkie cztery drogi zapisu, a nie tylko odczyt. W szczególności
mkstemp: przy katalogu tylko do odczytu wywala się ONO pierwsze, jeszcze zanim
dojdzie do zapisu i podmiany — więc obudowanie samego os.replace nic by nie dało
(złapane testem, nie przeglądem kodu).

USZKODZONY PLIK NIE JEST NADPISYWANY. Wcześniej niepoprawny JSON dawał pusty
zbiór kont, co przy pierwszym zapisie skasowałoby WSZYSTKIE konta bez śladu.
Teraz to odmowa z komunikatem — plik zostaje nietknięty, a test tego pilnuje.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:42:16 +00:00
gitea 003deb9404 feat(dane): sterownik Postgresa pod lustro w SQL (DAN-28)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m40s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 16s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 9s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m43s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 16m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 11m40s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 8m15s
Testy / Kontrola składni wszystkich warstw (push) Successful in 1m45s
build / build (push) Successful in 11m21s
Warstwa danych ma SQLAlchemy, ale nie miała czym rozmawiać z Postgresem. Bez
psycopg adres `postgresql+psycopg://…` wywala się dopiero przy PIERWSZYM
połączeniu — już na klastrze, komunikatem o braku modułu. [binary] bierze gotowe
koło, więc obraz nie kompiluje libpq.

Sam SQL_URL niczego jeszcze nie przełącza: o dostawcy decyduje DATA_PROVIDER,
które zostaje na `excel`, dopóki lustro nie jest zaimplementowane i sprawdzone.
Dzięki temu Postgresa da się wdrożyć i obejrzeć bez ryzyka dla działającego
wyszukiwania.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:16:43 +02:00
gitea 6466ab89a9 feat(dane): interfejs zarządzania plikami baz — trzy poziomy dostępu (DAN-27)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m12s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 19s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 12m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 16s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
Ekran „Pliki" z trzema poziomami, wpiętymi w kontrolę dostępu z PRE-27:

  „files"        widzi listę i KLIKANIEM decyduje, z których baz program korzysta,
  „files_input"  dokłada wgrywanie i ARCHIWIZACJĘ,
  administrator  kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.

STAN JEST TERAZ TRWAŁY. DAN-15 trzymał go w zmiennej DISABLED_BASES, bo warstwa
danych nie miała gdzie zapisywać — udział był montowany read-only. Skoro stan ma
być klikany, musi przetrwać restart, więc udział jest zapisywalny, a stan leży
w pliku obok baz (zapis atomowy: plik opisuje CAŁY zbiór, więc obcięcie w połowie
skasowałoby wiedzę o wszystkich naraz). DISABLED_BASES zostaje jako awaryjne
wyłączenie z konfiguracji i odsiewa DODATKOWO — nie odwrotnie, bo inaczej ktoś
z dostępem do ekranu włączyłby bazę wyłączoną świadomie na poziomie wdrożenia.

ARCHIWIZACJA NIE KASUJE. Plik zostaje na dysku, zamrożony, ze znacznikiem czasu;
znika wyłącznie z użytku. To najdalej idąca operacja osoby wgrywającej dane —
kasować może tylko administrator. Test sprawdza, że plik po archiwizacji nadal
istnieje, bo to jest cała istota tej operacji.

WALIDACJA JEST BRAMKĄ DO UŻYTKU, NIE FILTREM NA WEJŚCIU. Plik wgrany zostaje
NIEZALEŻNIE od wyniku — nie tracimy niczego, co ktoś wgrał. Zmienia się tylko to,
czy da się go włączyć. Sprawdzenie biegnie też w chwili włączania, nie tylko przy
wgrywaniu: reguły mogą się zmienić po fakcie.

O WALIDACJI WIE TYLKO ADMINISTRATOR. Pliki wstrzymane są odsiewane W WARSTWIE
DANYCH przy for_admin=False, a nie ukrywane w szablonie — gdyby dochodziły do
przeglądarki, wystarczyłby podgląd źródła, żeby poznać reguły. Odmowa włączenia
wraca do konta bez uprawnień BEZ POWODU, bo powód zdradza regułę. Sekcja reguł
nie trafia nawet do źródła strony. Test parametryzowany po obu niższych poziomach
szuka w odpowiedzi śladów mechanizmu i wymaga, żeby żadnego nie było.

Każdy plik ma policzony sha256 — tożsamość niezależna od nazwy. Wykorzystuje ją
już odrzucanie duplikatów, a w kroku drugim posłuży do pilnowania zgodności
lustra w SQL.

Przy okazji naprawiony błąd, który dopiero co bym wprowadził: Path("") to
Path("."), czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna`
zawsze wybierało pustą zmienną i zapisywało stan do katalogu bieżącego.

Wymaga zapisywalnego udziału — osobny PR w repo deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:39:42 +02:00
gitea a833965909 feat(bezpieczeństwo): konta z uprawnieniami do zakładek i funkcji (PRE-27)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m58s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 3m37s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
build / build (push) Successful in 18s
Ekran „Konta" dla administratora: zakładanie, kasowanie i nadawanie uprawnień.
Zestaw funkcji zależy od konta, a konto ograniczone widzi program KOMPLETNY —
tylko mniejszy.

PODZIAŁ NA GRUPY. Ekrany to zakładki (7), bo zakładka jest naturalną jednostką —
to ją widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI wewnątrz ekranów:
porównanie systemów domów, wykresy dodatkowe, obliczenia zaawansowane, generowanie
tekstu przez model (kosztuje pieniądze) i eksport plików. Konto bez porównania
domów dostaje horoskop w Whole Sign i nie wie, że systemów jest trzynaście.

NIC NIE ZDRADZA, ŻE JEST WIĘCEJ:
- brak pozycji w menu zamiast pozycji wyszarzonej,
- 404 zamiast 403 — odmowa z powodem sama mówi, że coś tam jest,
- rysunki bez uprawnienia w OGÓLE NIE POWSTAJĄ, więc nie ma ich nawet w źródle,
- automatyczna dokumentacja API wyłączona. /docs, /redoc i /openapi.json wypisują
  komplet tras, czyli spis wszystkich funkcji programu — ochrona zakładek nic by
  nie dała, gdyby obok leżał ich katalog. Znalezione TESTEM przechodzącym po
  trasach aplikacji, nie przeglądem kodu.

KONTO ADMINISTRACYJNE zostaje w APP_USER/APP_PASSWORD, jak było. Nie leży w pliku
kont, więc nie da się go skasować ani ograniczyć z ekranu. Konto założone w pliku
o tym samym loginie NIE przesłoni administracyjnego — kolejność sprawdzania jest
odwrotna, inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać.
Uprawnienia administracyjnego nie da się też nadać z formularza: odsiewamy je
w normalise(), a nie w handlerze, więc żadne spreparowane żądanie tam nie sięgnie.

GRANICA JEST W HANDLERZE, NIE W SZABLONIE. Ukrycie pola chroni przed przypadkiem,
nie przed kimś, kto zna nazwy pól — _limit_options() ścina opcje po stronie
serwera i test wysyła spreparowane żądanie, żeby to potwierdzić.

MAPA TRASA→UPRAWNIENIE JEST JEDNA (features.ROUTES). Rozproszenie jej po
dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura
jest niewidoczna, dopóki ktoś jej nie znajdzie. Trasa bez wpisu wymaga
administratora: przeoczenie ma ZAMYKAĆ, nie otwierać. Test idzie po trasach
APLIKACJI, nie po wpisach mapy — inaczej potwierdzałby tylko sam siebie.

Konta w pliku JSON na własnym podkatalogu NFS (nie tam, gdzie bazy — zamontowanie
całego udziału obeszłoby bokiem DAN-25). Hasła wyłącznie jako hash scrypt, tym
samym mechanizmem co APP_USERS. Zapis atomowy, bo przerwanie zapisu na NFS
obcięłoby plik, czyli skasowało wszystkie konta naraz.

Przy okazji przepisane trzy testy, które greppowały nawigację i main.py: menu
powstaje teraz z katalogu funkcji, więc szukanie sztywnych linków w base.html
niczego już nie sprawdzało.

Wymaga wolumenu na konta — osobny PR w repo deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:18:14 +00:00
gitea baf4e0e38a fix(kosmogram): własne dymki zamiast natywnych — działają też po powiększeniu
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m51s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 19s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
build / build (push) Successful in 25s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m38s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
Rysunek niesie <title> przy każdym obiekcie i każdej linii aspektu i to
wystarczało, dopóki koło było statyczne. Po dodaniu powiększania na pełne okno
dymki przestały się pokazywać.

CO SPRAWDZIŁEM W PRZEGLĄDARCE (1280×900, komplet danych): elementy z <title>
SĄ osiągalne kursorem — 19 z 20 trafień w hit-teście — więc nic ich nie zasłania
i problem nie leży w geometrii ani w pointer-events. Której dokładnie reguły
przeglądarka używa do stłumienia natywnego dymka, nie ustaliłem.

Nie ma to jednak znaczenia, bo natywny dymek jest tu i tak kiepskim narzędziem:
pojawia się po sekundzie zwłoki, nie da się go stylować, nie działa na dotyku
i nie ma go czym wywołać z klawiatury. Własny dymek usuwa zależność od zachowania
przeglądarki i przy okazji jest czytelniejszy.

Tekst bierzemy z <title> JUŻ OBECNEGO w rysunku, a nie z drugiej kopii opisów
w JS — inaczej rozjechałyby się przy pierwszej zmianie treści. Na czas najechania
<title> jest odpinany i wieszany z powrotem po zejściu kursora: dzięki temu nigdy
nie widać dwóch dymków naraz, a czytniki ekranu zachowują nazwę dostępną.

Warstwa 1150 CELOWO pomiędzy: nad nakładką powiększonego koła (1100), bo tam
właśnie zgłoszono problem, i pod oknem postępu (1200), które ma zostać na wierzchu
podczas pisania horoskopu. Test pilnuje tej kolejności liczbowo.

Ścieżka PDF nietknięta — składa się po stronie serwera, <title> zostają w rysunku.

Zweryfikowane w obu stanach koła: dymek pokazuje „Sun · 12°30'00'' · dom 1" nad
glifem i „Sun trygon Moon · orb 3.64°" nad linią aspektu, po powiększeniu też
(kursor-lupa mu nie przeszkadza), a po zejściu kursora wszystkie 20 <title>
wraca na miejsce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:31:07 +02:00
gitea 8b6ecc727d feat(domy): przypisanie do domu przez wyrocznię + poprawki układu strony
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m44s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 20s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
build / build (push) Successful in 23s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m38s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
ETAP 5, część pierwsza: assign_house pod wyrocznią (swe_house_pos).
Osobny rodzaj błędu niż same cuspy — i od razu jeden znalazł.

BŁĄD: assign_house szło ZAWSZE do przodu. Przy dużych szerokościach systemy
dzielące koła wielkie mają kolejność domów ODWRÓCONĄ (przy φ=−84,3° cusp domu I
wypada na 174,2°, a domu II na 165,3°) — co nie jest usterką, bo wyrocznia zwraca
dokładnie te same wartości. Suma przeskoków „do przodu" wychodziła 3960° zamiast
360°, czyli każdy krok obchodził koło dookoła. Planety lądowały w złych domach
dla regiomontanusa, campanusa i topocentrica: 9,7-12,5% przypadków. Błąd cichy —
wykres wyglądał bez zarzutu. Kierunek bierzemy teraz z samych cuspów.

TOPOCENTRIC MA JEDNAK GRANICĘ DZIEDZINY — korekta tego, co pisałem wcześniej.
Cuspy są poprawne wszędzie (zgodne z wyrocznią co do zera), ale powyżej koła
podbiegunowego przestają DZIELIĆ OKRĄG: cusp VII (= I + 180°) wypada przed
cuspem VI i domy nachodzą na siebie. Przypisanie planety traci wtedy sens —
co potwierdza sama wyrocznia, której swe_house_pos przeczy tam własnym cuspom
(100% zgodności do 62°, 83,9% przy 66°, ok. 50% przy 72°; regiomontanus 100%
w tych samych punktach). Odmawiamy, z jawnym fallbackiem jak Placidus i Koch.

Próg jest WYPROWADZONY z warunku „dwanaście cuspów sumuje się do 360°", nie
dobrany pod wynik testu — i wypada na kole podbiegunowym (zmierzone: 100%
podziałów do 65°, 78% w pasie 66-67°). To inny rodzaj granicy niż u Placidusa
i Kocha: tam nie istnieją same cuspy, tu istnieją, tylko nie tworzą podziału.

Framework dostał pojęcie dziedziny WĘŻSZEJ niż wyroczni (NARROWER_THAN_ORACLE),
zamiast wyjątku „bo topocentric": skoro wyrocznia przeczy sama sobie, nie może
rozstrzygać, więc tam nie porównujemy — a nasze przypisanie jest w tym obszarze
sprawdzane testem samospójności z cuspami, bez swissepha.

UKŁAD STRONY — zmierzony na żywej stronie, nie na oko:
- tabela porównania przy 13 systemach miała 14 kolumn i 1863 px, a stała
  w rodzicu bez overflow-x, więc ROZPYCHAŁA CAŁY DOKUMENT: 1713 px przy oknie
  1280 px, poziomy pasek na body. Teraz ma własny kontener przewijany
  (dokument 1265 px, nie przewija się), a numer domu jest przyklejony do lewej,
  bo inaczej po przewinięciu nie wiadomo, który to wiersz.
- przypis „* nie działa za kołem podbiegunowym" siedział WEWNĄTRZ <label>
  selektora, łamał się na dwie linie i rozciągał wiersz siatki ze 66 do 108 px,
  rozjeżdżając go z sąsiednim polem. Wyjaśnienie stoi teraz raz, przy
  checkboxach z gwiazdkami; wiersz wrócił do 66 px.
- 13 checkboxów na flexie zawijało się w poszarpane wiersze — jest siatka
  o stałej szerokości kolumny (auto-fill, więc na wąskim ekranie kolumn mniej).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:55:13 +02:00
gitea 21a00b0000 feat(silnik B): endpoint /houses + nocny przemiał; ε PRAWDZIWE zamiast średniego
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m36s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 24s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 10s
build-swisseph / build (push) Successful in 18s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m37s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 16s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
Etap 4, z jedną istotną zmianą planu i jednym znalezionym błędem.

/houses W SILNIKU B
Domyka kontrakt parzystości (LOG-28) po stronie domów — dotąd obejmował tylko
pozycje obiektów, więc błąd w podziale na domy przechodził przez porównanie
silników niezauważony. Nazwy systemów są NASZE (te same, co houses.SYSTEMS),
więc wołający nie musi znać liter swissepha; rozjazd tych dwóch list oznaczałby,
że parzystość przestała obejmować część systemów.

Poza dziedziną (Placidus/Koch za kołem podbiegunowym) zwracamy 422 z powodem,
a NIE podstawiamy po cichu innego systemu — cicha podmiana jest po stronie
wołającego niewykrywalna, a to on ma zdecydować, co z tym zrobić.

PRZEMIAŁ: NOCNE CI ZAMIAST CRONJOBA W KLASTRZE
Plan zakładał Job w k3s, bo „duży przemiał jest kosztowny". Pomiar tego nie
potwierdził: 500 000 przypadków × 13 systemów = 70 mln porównań w 64 sekundy,
skalowanie liniowe (20k→2,8 s, 100k→12,3 s, 500k→64 s). Osobny obraz w rejestrze,
manifest, CronJob i kopia harnessu poza repo byłyby infrastrukturą do problemu,
którego nie ma — a kopia harnessu poza repo to ryzyko cichego rozjazdu z kodem,
który ma testować. Workflow z harmonogramem daje to samo: co noc inne ziarno,
więc dziedzina przeczesuje się z czasem gęściej niż pojedynczym przebiegiem.

ε PRAWDZIWE — BŁĄD ZNALEZIONY PRZY OKAZJI
Silnik liczył RAMC z GAST (czas gwiazdowy POZORNY, mierzony od równonocy
PRAWDZIWEJ), ale parował go z ε ŚREDNIM, czyli bez nutacji. To nie wybór
konwencji, tylko pomieszanie dwóch układów odniesienia. Skutek: do 3,2″ na
cuspach domów oraz niespójne ε dla deklinacji i antyscji, liczonych z pozycji
POZORNYCH. Teraz ε pochodzi z serii IAU 2000A — z tego samego źródła, którego
Skyfield używa do GAST, więc oba są spójne z definicji.

Framework wyroczni tego NIE MÓGŁ wykryć: z założenia podaje to samo ε obu
stronom, żeby izolować samą funkcję domów. Błąd siedział w danych WEJŚCIOWYCH,
nie w testowanej funkcji — i cały czas świecił na zielono. Wejście ma więc teraz
własny sprawdzian, ze Skyfieldem jako niezależnym autorytetem (bez swissepha,
więc działa w każdym środowisku). Luka opisana wprost w tests/oracle/README.md,
bo poprzedni tekst twierdził, że ε jest testowane — nie było.

Reszta ~3″ przy porównaniu „cały horoskop nasz vs swissepha" to UT1 kontra UTC:
Skyfield konwertuje z tablic IERS, swisseph przyjmuje podany JD jako UT1 (dla
1984-04-30 różnica 0,181 s = 2,7″ RAMC — zgadza się co do trzeciego miejsca).
Podanie swissephowi JD w UT1 kasuje ją do 0,00065″. Nasza strona jest dokładniejsza;
niczego tu nie zmieniam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:22:48 +02:00
gitea 4d1daab35a feat(domy): warianty od Barana i od MC + obrót kosmogramu — LOG-05 zamknięte
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 18s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 10s
build / build (push) Successful in 26s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m37s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m27s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 10s
Ostatnie trzy pozycje z treści LOG-05, których wcześniej nie było:

- whole_sign_aries — znaki jako domy, ale dom I ZAWSZE na 0° Barana, niezależnie
  od Ascendentu (tradycja indyjska i część szkół hellenistycznych),
- equal_mc — równe domy zakotwiczone na MC: dom X zaczyna się dokładnie na
  południku, a nie ma go gdzieś w środku,
- obrót kosmogramu: Ascendent albo 0° Barana po lewej stronie koła. Zmienia
  WYŁĄCZNIE rysunek, żadna liczba nie jest przeliczana. Wariant „od Barana"
  unieruchamia koło względem zodiaku, więc dwa horoskopy da się porównać na oko.

Oba nowe systemy zgodne z wyrocznią co do zera od pierwszego uruchomienia.
Razem 13 systemów: 2 833 424 porównania w przemiale, zero przekroczeń.

PRZY OKAZJI — DWA BŁĘDY, KTÓRE SAM WPROWADZIŁEM I KTÓRYCH TESTY NIE WIDZIAŁY:
- linia zbierająca ostrzeżenia o fallbacku trafiła do handlera strony głównej
  zamiast do compile_pdf: odwoływała się do nieistniejącej zmiennej, czyli 500
  na stronie głównej, a do PDF-a ostrzeżenia nie docierały wcale,
- compile_build używał parametru formularza, którego nie miał w sygnaturze.

Oba przeszły przez komplet zielonych testów, bo żaden nie wywoływał POST-a —
testy prezentacji sprawdzały teksty w szablonach i w main.py. Doszły więc testy
uderzające w prawdziwe trasy (POST / i POST /compile ze stubowaną logiką), które
łapią tę klasę błędu.

Z tego samego powodu przepisane dwa testy PDF-a: greppowały z main.py dokładny
kształt wywołania render(chart, theme="print") i pękały przy dopisaniu argumentu,
mimo poprawnego zachowania. Teraz wołają trasę i sprawdzają, że KAŻDY z czterech
rysunków dostaje motyw druku.

LOG-05 i PRE-05 → Zrobione w docs/astrololo_wymagania.xlsx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:38:43 +02:00
gitea aec3f84331 feat(domy): topocentric zgodny co do zera — wybór gałęzi liczony, nie zgadywany
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m29s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 18s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 9s
build-render / build (push) Successful in 5m34s
build / build (push) Successful in 39s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m31s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m27s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 19s
Testy / Kontrola składni wszystkich warstw (push) Successful in 11s
Topocentric był wycofany z powodu rozjazdu przy |φ| ≈ 89,9°. Ta diagnoza była
BŁĘDNA: opierała się wyłącznie na zestawie brzegowym, który próbkuje tylko wybrane
szerokości. Przemiał losowy pokazał prawdziwą skalę — błędy od ~70° w górę,
do 50% przypadków blisko biegunów, 6,5% całości. Nie dwa przypadki brzegowe.

Przyczyną nie była jednak konstrukcja, tylko wybór gałęzi: dwa koła wielkie
przecinają się w dwóch punktach antypodycznych. Kolejno zawiodły reguły „po której
stronie MC", „w łuku kwadrantu", „wschodnia połowa horyzontu" i śledzenie ciągłości
krokami (to ostatnie maskuje własną patologię — po korekcie do bliższej gałęzi
zmierzony skok ZAWSZE wychodzi ≤ 90°, więc detektor nigdy się nie zapala).

Wszystkie te reguły rozstrzygają lokalnie, a przy dużych szerokościach kolejność
domów potrafi się odwrócić: przy φ = −79,55° MC wypada na 306,8°, a dom 11 na
291,4°. To jest poprawne — wyrocznia zwraca to samo.

Rozwiązanie: nie wybierać w ogóle. Iloczyn wektorowy zenitu z biegunem ekliptyki
jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot; dwuznaczność
wprowadza dopiero atan2. Zostajemy w wektorach, znak ustalamy raz — kotwicząc
rodzinę na MC górującym.

Wynik: 0,000000000° na 240 000 porównań, cała dziedzina, bez iteracji i bez
zawężania szerokości. Żadna granica dziedziny nie jest tu potrzebna, więc żadnej
nie udajemy — test pilnuje, że topocentric liczy się wszędzie i nie fallbackuje.

Morał do frameworka: zestaw brzegowy mówi, CZY system się psuje; dopiero przemiał
losowy mówi JAK BARDZO. Wniosek o skali wyciągnięty z samych brzegów był tu
zaniżony o trzy rzędy wielkości. Odnotowane w tests/oracle/README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:24:40 +02:00
127 changed files with 7646 additions and 1167 deletions
+59
View File
@@ -0,0 +1,59 @@
name: Wyrocznia domów — nocny przemiał
# DLACZEGO TU, A NIE JAKO JOB W KLASTRZE
# Pierwotny plan zakładał CronJob w k3s, bo „duży przemiał jest kosztowny".
# Pomiar tego nie potwierdził: 500 000 przypadków × 13 systemów = 70 mln porównań
# w 64 sekundy, skalowanie liniowe. Osobny obraz w rejestrze (który już raz zapchał
# dysk hosta), manifest, CronJob i kopia harnessu poza repo byłyby infrastrukturą
# do problemu, którego nie ma — a każda kopia harnessu poza repo to ryzyko cichego
# rozjazdu z kodem, który ma testować.
#
# Zestaw brzegowy blokuje KAŻDY build (patrz tests.yml). Tutaj chodzi o co innego:
# duża losowa próbka z INNYM ZIARNEM co noc, żeby z czasem przeczesać dziedzinę
# gęściej, niż zrobi to pojedynczy przebieg.
on:
schedule:
- cron: '17 2 * * *' # 02:17 — poza godzinami budowania
workflow_dispatch:
inputs:
count:
description: 'Liczba przypadków'
default: '500000'
seed:
description: 'Ziarno (puste = z daty)'
default: ''
jobs:
sweep:
name: Przemiał losowy (13 systemów)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Obraz wyroczni (silnik B + nasza logika + harness)
run: |
docker build -t astrololo/engine-swisseph:sweep services/engine-swisseph
# Pliki WBUDOWANE, nie montowane: job Gitea Actions sam działa w kontenerze,
# więc `-v $PWD/...` rozwiązałoby się na HOŚCIE (docker cicho tworzy pusty
# katalog i skrypt „znika"). Kontekst builda jest strumieniowany do demona.
docker build -t astrololo/oracle:sweep -f - . <<'DOCKERFILE'
FROM astrololo/engine-swisseph:sweep
COPY services/logic /logic
COPY tests/oracle /oracle
ENV LOGIC_PATH=/logic
DOCKERFILE
- name: Przemiał
run: |
COUNT="${{ inputs.count }}"; COUNT="${COUNT:-500000}"
SEED="${{ inputs.seed }}"; SEED="${SEED:-$(date -u +%Y%m%d)}"
echo "count=$COUNT seed=$SEED"
# Kod wyjścia 1 przy przekroczeniu tolerancji ALBO niezgodności dziedziny,
# więc job czerwieni się sam — bez parsowania tekstu raportu.
docker run --rm astrololo/oracle:sweep \
python /oracle/run.py --mode sweep --count "$COUNT" --seed "$SEED"
# Runner miał już incydent „no space left on device" — sprzątamy zawsze.
- name: Usuń obrazy pomocnicze
if: always()
run: docker rmi -f astrololo/oracle:sweep astrololo/engine-swisseph:sweep 2>/dev/null || true
+31
View File
@@ -138,6 +138,37 @@ jobs:
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
# /houses — kontrakt parzystosci (LOG-28) po stronie DOMOW. Sprawdzamy
# nie tylko, ze liczy, ale i ze ODMAWIA tam, gdzie system nie istnieje:
# ciche podstawienie innego systemu byloby niewykrywalne dla wolajacego.
from fastapi import HTTPException
from app.main import _HOUSE_CODES, HousesRequest, houses
WHEN = "1984-04-30T09:20:00Z"
h = houses(HousesRequest(when_utc=WHEN, lat=50.0647, lon=19.9450, system="campanus"))
assert h["engine"] == "swisseph" and h["system"] == "campanus"
assert len(h["cusps"]) == 12, h["cusps"]
assert [c["house"] for c in h["cusps"]] == list(range(1, 13))
assert all(0.0 <= c["longitude"] < 360.0 for c in h["cusps"])
assert {"Asc", "MC", "ARMC"} <= set(h["angles"]), h["angles"]
# kazdy ogloszony system musi dac 12 cuspow tam, gdzie ma definicje
for name in _HOUSE_CODES:
out = houses(HousesRequest(when_utc=WHEN, lat=50.0647, lon=19.9450, system=name))
assert len(out["cusps"]) == 12, name
print(f"/houses: {len(_HOUSE_CODES)} systemow OK")
for bad, why in ((dict(lat=69.65, lon=18.96, system="placidus"), "Tromso/placidus"),
(dict(lat=50.0, lon=19.0, system="nie-ma-takiego"), "nieznany system")):
try:
houses(HousesRequest(when_utc=WHEN, **bad))
raise AssertionError(f"{why}: mialo byc 422, a przeszlo")
except HTTPException as e:
assert e.status_code == 422, (why, e.status_code)
print("/houses: odmowy poza dziedzina OK")
print("SMOKE OK")
PY
+2
View File
@@ -16,6 +16,8 @@ services/logic/.ephemeris/
# Dane wejściowe (duże pliki Excela trzymane poza repo)
services/data/data_files/*.xlsx
# Rejestr plików (DAN-27) powstaje przy uruchomieniu — to stan, nie kod.
services/data/data_files/.files-state.json
!services/data/data_files/.gitkeep
# Narzędzia
+12 -6
View File
@@ -11,8 +11,10 @@ services:
volumes:
- ./services/data/data_files:/app/data_files
- data_cache:/app/.cache
ports:
- "8002:8002"
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
logic:
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
@@ -25,8 +27,10 @@ services:
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on:
- data
ports:
- "8001:8001"
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
# docker compose --profile comparison up
@@ -36,8 +40,10 @@ services:
container_name: astrololo-engine-swisseph
restart: unless-stopped
profiles: ["comparison"]
ports:
- "8003:8003"
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
presentation:
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
+12 -6
View File
@@ -9,8 +9,10 @@ services:
volumes:
- ./services/data/data_files:/app/data_files
- data_cache:/app/.cache
ports:
- "8002:8002"
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
logic:
build: ./services/logic
@@ -21,8 +23,10 @@ services:
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on:
- data
ports:
- "8001:8001"
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
# docker compose --profile comparison up
@@ -30,8 +34,10 @@ services:
engine-swisseph:
build: ./services/engine-swisseph
profiles: ["comparison"]
ports:
- "8003:8003"
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
presentation:
build: ./services/presentation
Binary file not shown.
+168
View File
@@ -0,0 +1,168 @@
# Konta i uprawnienia (PRE-27)
Rozszerzenie kont imiennych z [PRE-17](konta-i-audyt.md): konta zakłada się
**z aplikacji**, a każde dostaje własny zestaw funkcji.
## Dwie zasady, z których wynika reszta
**1. Konto ograniczone widzi program KOMPLETNY — tylko mniejszy.**
Nic nie może zdradzać, że istnieje coś więcej. Żadnych wyszarzonych zakładek,
żadnego „brak uprawnień", żadnego 403 — bo **403 samo w sobie jest informacją**,
że pod tym adresem coś jest. Ścieżka bez uprawnienia odpowiada **404**, tak samo
jak adres, którego nie ma.
Z tej zasady wynikło też wyłączenie `/docs`, `/redoc` i `/openapi.json`.
Automatyczna dokumentacja FastAPI wypisuje komplet tras — czyli spis wszystkich
funkcji programu. Ochrona zakładek nic by nie dała, gdyby obok leżał ich katalog.
(Znalezione testem, nie przeglądem kodu.)
**2. Konto administracyjne pochodzi WYŁĄCZNIE ze środowiska.**
`APP_USER` / `APP_PASSWORD` (albo `APP_USERS`) — jak dotąd. To konto ma wszystkie
uprawnienia i jako jedyne zarządza pozostałymi. **Nie leży w pliku kont**, więc
nie da się go skasować ani ograniczyć z ekranu — nawet przez pomyłkę, nawet
spreparowanym żądaniem. Konto założone w pliku o tym samym loginie **nie
przesłoni** administracyjnego (kolejność sprawdzania jest odwrotna).
## Podział funkcji
**Ekrany** — zakładki widoczne w nawigacji:
| klucz | zakładka |
|---|---|
| `chart` | Horoskop |
| `interpret` | Interpretacje |
| `timeline` | Kalendarz |
| `synastry` | Synastria |
| `significators` | Sygnifikatory |
| `compile` | Skompiluj |
| `settings` | Ustawienia |
**Rozszerzenia** — poziomy złożoności wewnątrz ekranów:
| klucz | co daje |
|---|---|
| `houses_compare` | wybór systemu domów, zestawienie kilku obok siebie, obrót koła |
| `extra_charts` | aspektarian, wykres deklinacji, oś antyscji |
| `advanced_calc` | stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne |
| `ai` | horoskopy pisane przez model językowy (**każde użycie kosztuje**) |
| `export` | pobieranie PDF i Excela |
Konto bez `houses_compare` dostaje horoskop w Whole Sign i **nie widzi**, że
systemów jest trzynaście. Konto bez `ai` nie zobaczy przycisku generowania ani
nie wywoła go z pominięciem interfejsu.
## Gdzie leży granica
W handlerze, nie w szablonie. Ukrycie pola w formularzu chroni przed przypadkiem,
ale nie przed kimś, kto zna nazwy pól — dlatego `_limit_options()` ścina opcje
**po stronie serwera**, a rysunki dodatkowe bez uprawnienia w ogóle nie powstają
(nie ma ich nawet w źródle strony).
Mapa `trasa → uprawnienie` jest **jedna**, w `app/features.py`. Rozproszenie jej
po dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura
jest niewidoczna do chwili, gdy ktoś ją znajdzie. Trasa bez wpisu w mapie wymaga
uprawnień administracyjnych: **przeoczenie ma zamykać, nie otwierać**. Test
przechodzi po wszystkich trasach aplikacji i wymaga, by każda była opisana.
### Gdy jedna trasa robi kilka rzeczy
Mapa tras nie wystarcza tam, gdzie jedna trasa obsługuje kilka funkcji naraz.
`POST /interpret` liczy horoskop, ale to samo pole `action` prosi o wygenerowanie
promptu, napisanie horoskopu przez model albo eksport arkusza. Konto, które ma
mieć Interpretacje bez generowania, musi dostać tę trasę — więc granica przebiega
wewnątrz niej, po akcjach: `_AKCJE_POD_UPRAWNIENIEM` przypisuje akcji uprawnienie,
a `_dozwolona_akcja()` sprowadza żądanie bez uprawnienia do akcji domyślnej ekranu.
Sprowadza — nie odrzuca. Komunikat „brak uprawnień do generowania" sam w sobie
mówiłby, że taka funkcja istnieje, czyli łamałby zasadę drugą po to, żeby
wyegzekwować pierwszą. Akcja bez uprawnienia ma wyglądać na literówkę w formularzu.
### Ślad to nie tylko przycisk
Wymaganie brzmi „nie może być śladu", i to jest mocniejsze niż schowanie kontrolki.
Największym wyciekiem po stronie generowania nie był przycisk, tylko **katalog
modeli** — nazwy dostawców, nazwy modeli i rozmiary okien kontekstu — wstrzykiwany
w stronę blokiem JSON na każdym ekranie z generowaniem, niezależnie od uprawnień.
Dlatego `_llm_catalog_for()` oddaje pusty katalog kontu bez uprawnienia, a szablony
trzymają pod bramką także znaczniki (`natalNote`, `reportNatal`), pliki skryptów
(`models.js`, `progress.js`, `natal.js`, `predictions.js`) i **zdania opisujące
funkcję** — podtytuł ekranu Skompiluj wymieniał interpretację od AI z nazwy.
Testu na to nie da się napisać przez „sprawdź, czy przycisku nie ma": trzeba
sprawdzić, że w źródle strony nie ma żadnego z tych śladów, i mieć kontrolę
pozytywną, że przy uprawnieniu wszystkie są. Inaczej test przechodzi także wtedy,
gdy generowanie jest zepsute dla wszystkich.
## Pełna paranoja: ukrywanie jest nadrzędne
Właściciel produktu postawił to wyżej niż wygodę i wyżej niż czytelność
komunikatów: *„nie chcę, żeby osoba wrzucająca bazy wiedziała, po co to robi
i jak będzie w przyszłości działał program, bo to rozgada"*. Persona nazywa się
**wgrywacz** — konto z uprawnieniami `files` + `files_input` i niczym więcej.
Nie ma dowiedzieć się: jakie inne funkcje istnieją, że teksty pisze model
językowy i u jakiego dostawcy, do czego służą wgrywane pliki, co jest planowane,
że istnieje walidacja plików, ani że istnieje konto, które widzi więcej.
### Wyciek prawie nigdy nie siedzi tam, gdzie się go szuka
Audyt sześciu kanałów potwierdził 26 wycieków. Ani jeden nie był przyciskiem.
| Kanał | Co wyciekało |
|---|---|
| `/static/**` poza bramką | komplet skryptów i arkuszy dla **niezalogowanego** |
| komentarze w CSS/JS | pełne zdania po polsku o funkcjach, o kwarantannie i o tym, że administrator widzi więcej |
| `styles.css` jako jeden plik | nazwy selektorów = spis funkcji programu |
| `base.html` | skrypty kosmogramu na **każdej** stronie, łącznie z „przyszłą zakładką" |
| komunikaty błędu | nazwa trasy, nazwa podsystemu, nazwa gałęzi rozwojowej, wewnętrzny `host:port` |
| komunikat po wgraniu | słowo „administrator" — i **dwie różne treści**, czyli wyrocznia do odgadywania reguł walidacji |
| `/openapi.json` warstw wewnętrznych | katalog wszystkich funkcji, bez logowania |
### Trzy zasady, które z tego wynikają
**Zasób jest częścią funkcji.** Skrypt i arkusz przechodzą przez tę samą bramkę
co ekran (`features.STATIC`). Nazwa pliku jest zgadywalna, więc plik publiczny
opowiada o funkcji równie dokładnie jak przycisk. Publiczny został jeden
`base.css` — bo potrzebuje go ekran logowania — i dlatego nie wolno w nim
umieścić niczego, co nazywa funkcję.
**Komentarz nie jedzie na drut.** `_asset_body()` usuwa komentarze przy
serwowaniu. Zostają w repozytorium, gdzie są potrzebne. Ta jedna zmiana zamyka
cztery z sześciu kanałów naraz.
**Różnica jest informacją.** Dwa różne komunikaty po wgraniu pliku były
wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać odpowiedź. Teraz
komunikat jest jeden, niezależnie od wyniku. Z tego samego powodu odmowa to
404 identyczne z „nie ma takiej trasy", a akcja bez uprawnienia cofa się do
domyślnej zamiast tłumaczyć, czego brakuje.
### Zapora słownikowa
Łatanie punkt po punkcie przegrywa z następną zmianą. Dlatego
`test_slownik_zakazany.py` nie sprawdza miejsc, tylko przechodzi **wszystko**,
co dane konto może pobrać, i szuka słów, które nie mają prawa paść.
Dwie z trzech list biorą się wprost z katalogu funkcji — nazwa funkcji, adres
jej ekranu i nazwy jej zasobów — więc dopisanie funkcji automatycznie dopisuje
je do tego, czego konto bez niej nie może zobaczyć. Trzecia lista, słownictwo
dziedziny i mechanizmów, jest pisana ręcznie, bo katalog jej nie zna.
Test ma kontrolę pozytywną: dla administratora te same słowa **muszą** się
pojawiać. Bez niej przechodziłby także wtedy, gdyby program był pusty.
## Gdzie leżą konta
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
na NFS — **własny podkatalog prezentacji**, nie katalog z bazami: zamontowanie
tutaj całego udziału obeszłoby bokiem zamknięcie dostępu z DAN-25.
Hasła wyłącznie jako hash scrypt, tym samym mechanizmem co `APP_USERS` — jedna
implementacja, więc nie ma czego rozjechać. Zapis jest **atomowy** (plik
tymczasowy + `os.replace` w tym samym katalogu): przerwanie zapisu nie obetnie
pliku, czyli nie skasuje wszystkich kont naraz.
## Czego ten mechanizm NIE robi
Nie zastępuje ochrony baz na poziomie sieci ani NFS (DAN-25). Ktoś z dostępem do
udziału albo do warstwy danych nadal je odczyta — uprawnienia w aplikacji
ograniczają to, co widać **przez aplikację**, i tyle.
+3
View File
@@ -37,6 +37,9 @@ class Settings:
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
# Warstwa SQL (po migracji)
# Domyślnie SQLite w cache — do testów i pracy lokalnej, bez stawiania bazy.
# Na klastrze SQL_URL wskazuje Postgresa i przychodzi z SEKRETU, bo niesie
# hasło (patrz deploy: astrololo/README-postgres.md).
sql_url: str = field(
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
)
+363
View File
@@ -0,0 +1,363 @@
"""Rejestr plików baz: stan użycia, wgrywanie, archiwizacja, walidacja (DAN-27).
CO SIĘ ZMIENIA WZGLĘDEM DAN-15. Dotąd włączanie i wyłączanie baz szło przez
zmienną `DISABLED_BASES` — deklaratywnie, bo warstwa danych nie miała gdzie
zapisywać stanu (udział read-only, cache jako emptyDir). Teraz stan jest KLIKANY,
więc musi być trwały: udział jest zapisywalny, a stan leży w pliku obok baz.
STANY PLIKU
active — bierze udział w wyszukiwaniu,
ready — sprawny, ale świadomie odstawiony; można włączyć jednym kliknięciem,
archived — ZAMROŻONY: nie bierze udziału, ma znacznik czasu archiwizacji,
sam plik zostaje nietknięty. To jedyna forma „usuwania" dostępna
osobie wgrywającej dane,
quarantine — wgrany, ale nie przeszedł walidacji. NIE JEST TRACONY; decyzję,
czy go skasować, podejmuje wyłącznie administrator.
DLACZEGO KWARANTANNA JEST NIEWIDOCZNA POZA ADMINISTRATOREM. Zasada z PRE-27 mówi,
że konto ograniczone nie ma skąd wiedzieć o mechanizmach, których nie obsługuje.
Gdyby plik w kwarantannie był widoczny z powodem odrzucenia, każdy wgrywający
poznałby reguły walidacji — a te są narzędziem administratora. Osoba wgrywająca
widzi więc plik jako „oczekuje na zatwierdzenie", bez powodu i bez reguł.
REGUŁY WALIDACJI są danymi, nie kodem: administrator ustawia je z ekranu. Trzymamy
je w tym samym pliku stanu, bo stan i reguły zmieniają się razem i muszą przetrwać
restart tak samo.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import tempfile
import threading
from datetime import datetime, timezone
from pathlib import Path
ACTIVE, READY, ARCHIVED, QUARANTINE = "active", "ready", "archived", "quarantine"
USABLE = frozenset({ACTIVE})
# Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych.
# Kwarantanna świadomie poza listą — patrz nagłówek modułu.
VISIBLE_TO_EVERYONE = frozenset({ACTIVE, READY, ARCHIVED})
_lock = threading.Lock()
DEFAULT_RULES: dict = {
"extensions": [".xlsx"],
"max_size_mb": 50,
"min_rows": 1,
"required_columns": [], # puste = bez wymagań co do nagłówków
"reject_duplicate_content": True,
}
def state_path(root: Path | str) -> Path:
"""Plik stanu — obok baz, chyba że wskazano inaczej.
Sprawdzamy NAPIS ze środowiska, nie Path(napis): Path("") to Path("."),
czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna` zawsze
wybierało pustą zmienną i zapisywało stan do katalogu bieżącego."""
override = os.getenv("FILES_STATE", "").strip()
return Path(override) if override else Path(root) / ".files-state.json"
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def sha256_of(path: Path | str) -> str:
"""Skrót treści pliku — tożsamość pliku niezależna od nazwy.
Przyda się też krokowi drugiemu (lustro w SQL): to po nim poznamy, że plik
na dysku rozjechał się z tym, co wczytano do bazy."""
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
# ── stan ─────────────────────────────────────────────────────────────────
def _read_state(root: Path) -> dict:
try:
with open(state_path(root), encoding="utf-8") as fh:
data = json.load(fh)
except (FileNotFoundError, json.JSONDecodeError):
data = {}
files = data.get("files")
rules = data.get("rules")
return {
"files": files if isinstance(files, dict) else {},
"rules": {**DEFAULT_RULES, **(rules if isinstance(rules, dict) else {})},
}
def _write_state(root: Path, data: dict) -> None:
path = state_path(root)
path.parent.mkdir(parents=True, exist_ok=True)
# Atomowo: plik stanu opisuje CAŁY zbiór baz, więc obcięcie go w połowie
# zapisu skasowałoby wiedzę o wszystkich naraz.
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except BaseException:
Path(tmp).unlink(missing_ok=True)
raise
def rules(root: Path | str) -> dict:
return _read_state(Path(root))["rules"]
def set_rules(root: Path | str, new: dict) -> dict:
root = Path(root)
with _lock:
data = _read_state(root)
merged = {**data["rules"]}
for key, value in (new or {}).items():
if key in DEFAULT_RULES:
merged[key] = value
data["rules"] = merged
_write_state(root, data)
return merged
# ── walidacja ────────────────────────────────────────────────────────────
def validate(path: Path | str, root: Path | str, *, digest: str = "",
known_digests: dict[str, str] | None = None) -> list[str]:
"""Lista POWODÓW odrzucenia. Pusta lista = plik nadaje się do użytku.
Zwracamy powody, a nie samo „tak/nie", bo administrator ma zobaczyć, CZEGO
plikowi brakuje — inaczej poprawianie bazy byłoby zgadywanką. Poza konto
administracyjne ta lista nie wychodzi."""
p, rs = Path(path), rules(root)
why: list[str] = []
exts = [str(e).lower() for e in rs.get("extensions") or []]
if exts and p.suffix.lower() not in exts:
why.append(f"rozszerzenie {p.suffix or '(brak)'} spoza dozwolonych: {', '.join(exts)}")
try:
size_mb = p.stat().st_size / (1024 * 1024)
except OSError:
return why + ["pliku nie da się odczytać"]
cap = float(rs.get("max_size_mb") or 0)
if cap and size_mb > cap:
why.append(f"rozmiar {size_mb:.1f} MB przekracza limit {cap:g} MB")
if rs.get("reject_duplicate_content") and known_digests:
digest = digest or sha256_of(p)
twin = next((name for name, d in known_digests.items()
if d == digest and name != p.name), None)
if twin:
why.append(f"treść identyczna z plikiem „{twin}")
required = [str(c).strip() for c in (rs.get("required_columns") or []) if str(c).strip()]
min_rows = int(rs.get("min_rows") or 0)
if required or min_rows:
why += _inspect_workbook(p, required, min_rows)
return why
def _inspect_workbook(path: Path, required: list[str], min_rows: int) -> list[str]:
"""Zagląda do arkusza: nagłówki i liczba wierszy.
read_only + tylko pierwszy arkusz — plik bazy potrafi mieć kilkadziesiąt MB,
a wczytanie go w całości przy każdym wgraniu zatkałoby usługę."""
try:
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
except Exception as e: # noqa: BLE001 — każdy błąd = powód
return [f"nie udało się otworzyć arkusza ({type(e).__name__})"]
why: list[str] = []
try:
ws = wb[wb.sheetnames[0]]
rows = ws.iter_rows(values_only=True)
header = [str(c).strip().lower() for c in (next(rows, ()) or ()) if c is not None]
missing = [c for c in required if c.strip().lower() not in header]
if missing:
why.append(f"brak wymaganych kolumn: {', '.join(missing)}")
if min_rows:
seen = sum(1 for i, _ in enumerate(rows) if i < min_rows)
if seen < min_rows:
why.append(f"za mało wierszy danych ({seen} < {min_rows})")
finally:
wb.close()
return why
# ── rejestr ──────────────────────────────────────────────────────────────
def _scan(root: Path) -> list[Path]:
"""Pliki na udziale, bez śmieci technicznych.
Pomijamy nie tylko ukryte PLIKI, ale i wszystko, co leży w ukrytym KATALOGU:
filtr po samej nazwie pliku wciągał do rejestru zawartość `.cache`, bo pliki
w środku nie zaczynają się od kropki. Efekt: cache podawany jako baza, a przy
pierwszym uruchomieniu jeszcze przyjmowany jako aktywny."""
def ukryta_sciezka(p: Path) -> bool:
return any(part.startswith(".") for part in p.relative_to(root).parts[:-1])
return [p for p in sorted(root.glob("**/*"))
if p.is_file() and not p.name.startswith((".", "~$"))
and not ukryta_sciezka(p)]
def _adopt_existing(root: Path) -> dict:
"""Pierwsze uruchomienie: bazy zastane na udziale są OD RAZU w użyciu.
Bez tego wdrożenie DAN-27 wyłączyłoby wyszukiwanie. Dotąd bazy działały
domyślnie (wyłączało się je jawnie przez DISABLED_BASES); po przejściu na
rejestr plik bez wpisu dostaje `ready`, czyli NIE w użyciu — więc pusty stan
po wdrożeniu oznaczałby, że program nagle niczego nie znajduje. Ta cicha
zmiana zachowania byłaby gorsza od awarii, bo wygląda jak pusta baza.
Rozróżnienie jest celowe: `ready` dotyczy plików WGRANYCH przez ekran (te
ktoś musi świadomie włączyć), a nie zastanych przy przejściu na rejestr.
Zapis stanu może się nie udać (udział read-only) — wtedy trudno, przy każdym
uruchomieniu przyjmiemy je na nowo. Zachowanie jest to samo, koszt żaden."""
files = {str(p.relative_to(root)): {"status": ACTIVE, "adopted_at": _now()}
for p in _scan(root)}
data = {"files": files, "rules": {**DEFAULT_RULES}}
try:
_write_state(root, data)
except OSError:
pass
return data
def registry(root: Path | str, *, for_admin: bool = False) -> list[dict]:
"""Pliki na udziale wraz ze stanem. `for_admin` odsłania kwarantannę i powody.
Filtrowanie siedzi TUTAJ, a nie w szablonie: gdyby pliki w kwarantannie
dochodziły do przeglądarki i były tylko ukrywane stylem, wystarczyłby podgląd
źródła strony, żeby poznać reguły walidacji."""
root = Path(root)
# Brak PLIKU stanu = pierwsze uruchomienie. Pusty słownik przy istniejącym
# pliku to co innego: ktoś świadomie wszystko odstawił, więc nie wskrzeszamy.
data = _read_state(root) if state_path(root).exists() else _adopt_existing(root)
out: list[dict] = []
for p in _scan(root):
rel = str(p.relative_to(root))
row = data["files"].get(rel, {})
status = row.get("status") or READY
if status == QUARANTINE and not for_admin:
continue
try:
st = p.stat()
size_mb = round(st.st_size / (1024 * 1024), 2)
modified = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d")
except OSError:
size_mb, modified = None, None
entry = {
"name": p.name, "path": rel, "size_mb": size_mb, "modified": modified,
"status": status, "in_use": status in USABLE,
# `enabled` to TA SAMA informacja pod nazwą, której używa reszta
# świata: endpoint /bases, warstwa logiczna i ekran „Ustawienia"
# (DAN-15/PRE-09). Rejestr wszedł w miejsce starej listy baz, więc
# musi mówić jej językiem — inaczej każdy odbiorca dostaje KeyError,
# a to była właśnie awaria /bases po wdrożeniu DAN-27.
"enabled": status in USABLE,
"archived_at": row.get("archived_at") or "",
"uploaded_at": row.get("uploaded_at") or "",
"uploaded_by": row.get("uploaded_by") or "",
"sha256": row.get("sha256") or "",
}
if for_admin:
entry["rejected_for"] = list(row.get("rejected_for") or [])
out.append(entry)
return out
def usable_paths(root: Path | str) -> list[str]:
"""Ścieżki baz, które FAKTYCZNIE biorą udział w wyszukiwaniu."""
root = Path(root)
return [str(root / e["path"]) for e in registry(root, for_admin=True) if e["in_use"]]
def _touch(root: Path, rel: str, **fields) -> dict:
with _lock:
data = _read_state(root)
row = {**data["files"].get(rel, {}), **fields}
data["files"][rel] = row
_write_state(root, data)
return row
def set_status(root: Path | str, rel: str, status: str, *, by: str = "") -> dict:
"""Zmienia stan pliku. Włączyć do użytku można TYLKO plik, który przeszedł
walidację — to jest właśnie ta bramka, o której mowa w wymaganiu."""
root = Path(root)
target = root / rel
if not target.is_file():
raise ValueError(f"Nie ma pliku „{rel}”.")
if status not in {ACTIVE, READY, ARCHIVED, QUARANTINE}:
raise ValueError(f"Nieznany stan: {status}")
data = _read_state(root)
current = (data["files"].get(rel) or {}).get("status") or READY
if status == ACTIVE:
if current == QUARANTINE:
raise ValueError("Plik nie może trafić do użytku.")
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True) if e["sha256"]}
why = validate(target, root, known_digests=known)
if why:
_touch(root, rel, status=QUARANTINE, rejected_for=why, checked_at=_now())
raise ValueError("Plik nie może trafić do użytku.")
fields = {"status": status, "changed_at": _now(), "changed_by": by}
if status == ARCHIVED:
# Znacznik czasu archiwizacji to wymóg: „zamrożona forma z timestampem".
fields["archived_at"] = _now()
elif status == ACTIVE:
fields["archived_at"] = ""
fields["rejected_for"] = []
return _touch(root, rel, **fields)
def store_upload(root: Path | str, filename: str, content: bytes, *, by: str = "") -> dict:
"""Zapisuje wgrany plik i od razu go sprawdza.
Plik zostaje NIEZALEŻNIE od wyniku walidacji — nie tracimy niczego, co ktoś
wgrał. Zmienia się tylko to, czy da się go włączyć do użytku."""
root = Path(root)
safe = re.sub(r"[^A-Za-z0-9._ -]", "_", Path(filename or "").name).strip() or "plik"
target = root / safe
stem, suffix, n = Path(safe).stem, Path(safe).suffix, 1
while target.exists(): # nie nadpisujemy cudzej bazy
target = root / f"{stem}-{n}{suffix}"
n += 1
root.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
rel = str(target.relative_to(root))
digest = sha256_of(target)
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True)
if e["sha256"] and e["path"] != rel}
why = validate(target, root, digest=digest, known_digests=known)
_touch(root, rel, status=QUARANTINE if why else READY, rejected_for=why,
sha256=digest, uploaded_at=_now(), uploaded_by=by, checked_at=_now())
return {"path": rel, "name": target.name, "accepted": not why}
def delete(root: Path | str, rel: str) -> None:
"""Nieodwracalne skasowanie pliku — wyłącznie dla administratora."""
root = Path(root)
target = root / rel
if not target.is_file():
raise ValueError(f"Nie ma pliku „{rel}”.")
target.unlink()
with _lock:
data = _read_state(root)
data["files"].pop(rel, None)
_write_state(root, data)
+83 -3
View File
@@ -7,13 +7,16 @@ ani prezentacji.
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
import base64
import binascii
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from app import canary, link_crypto, security
from app import canary, files, link_crypto, security
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from pydantic import BaseModel
from app.providers.factory import build_provider
provider = build_provider(settings)
@@ -25,7 +28,12 @@ async def lifespan(app: FastAPI):
yield
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan,
docs_url=None, redoc_url=None, openapi_url=None)
security.install(app, "danych") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
@@ -54,6 +62,78 @@ def bases() -> dict:
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
# ── zarządzanie plikami baz (DAN-27) ─────────────────────────────────────
# Warstwa danych jest właścicielem plików, więc to ona nimi zarządza. Uprawnienia
# rozstrzyga PREZENTACJA (PRE-27) i przekazuje tu wynik jako `for_admin` / `by` —
# ta warstwa nie zna kont i nie ma jak ich znać. Nie jest to dziura: warstwa
# danych stoi za tokenem międzywarstwowym i szyfrowanym łączem, więc rozmawia
# z nią wyłącznie warstwa logiczna.
class FilesQuery(BaseModel):
for_admin: bool = False
class FileAction(BaseModel):
path: str
status: str = ""
by: str = ""
class FileUpload(BaseModel):
filename: str
content_b64: str
by: str = ""
class RulesUpdate(BaseModel):
rules: dict
@app.post("/files")
def files_list(q: FilesQuery) -> dict:
"""Rejestr plików. Kwarantanna WYCHODZI stąd tylko przy for_admin — filtrujemy
u źródła, żeby nie dało się jej odczytać z podglądu źródła strony."""
root = settings.excel_dir
return {"files": files.registry(root, for_admin=q.for_admin),
"rules": files.rules(root) if q.for_admin else {},
"root": str(root)}
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
try:
row = files.set_status(settings.excel_dir, a.path, a.status, by=a.by)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"path": a.path, "status": row.get("status")}
@app.post("/files/upload")
def files_upload(u: FileUpload) -> dict:
"""Plik wędruje w base64 wewnątrz zaszyfrowanego łącza — tym samym kanałem,
co reszta ruchu międzywarstwowego. Osobny, nieszyfrowany kanał na pliki
byłby obejściem PRE-16."""
try:
raw = base64.b64decode(u.content_b64, validate=True)
except (binascii.Error, ValueError) as e:
raise HTTPException(422, "Nieczytelna zawartość pliku.") from e
return files.store_upload(settings.excel_dir, u.filename, raw, by=u.by)
@app.post("/files/delete")
def files_delete(a: FileAction) -> dict:
try:
files.delete(settings.excel_dir, a.path)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"deleted": a.path}
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return {"rules": files.set_rules(settings.excel_dir, u.rules)}
@app.get("/health", response_model=HealthInfo)
def health() -> HealthInfo:
return provider.health()
+20 -7
View File
@@ -92,20 +92,33 @@ class ExcelDataProvider(DataProvider):
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
def _enabled_files(self, paths: list[str]) -> list[str]:
"""Odsiewa bazy WYŁĄCZONE globalnie (DAN-15) — nie biorą udziału
w interpretacji, choć fizycznie leżą na udziale."""
from app import bases
"""Bazy biorące udział w wyszukiwaniu.
Źródłem prawdy jest REJESTR PLIKÓW (DAN-27) — stan klikany z ekranu,
trwały na udziale. Zmienna DISABLED_BASES z DAN-15 zostaje jako awaryjne
wyłączenie z konfiguracji: gdy jest ustawiona, odsiewa DODATKOWO. Nie
odwrotnie — inaczej ktoś z dostępem do ekranu mógłby włączyć bazę
wyłączoną świadomie na poziomie wdrożenia.
"""
# `bases` MUSI być zaimportowane tutaj — modułowego importu nie ma,
# a przepisując tę funkcję pod rejestr usunąłem lokalny. Efekt: NameError
# przy KAŻDYM wyszukiwaniu, czyli 500 z warstwy danych.
from app import bases, files
usable = set(files.usable_paths(self.s.excel_dir))
out = [p for p in paths if p in usable]
entries = bases.disabled_entries()
if not entries:
return paths
return [p for p in paths if bases.is_enabled(p, self.s.excel_dir, entries)]
if entries:
out = [p for p in out if bases.is_enabled(p, self.s.excel_dir, entries)]
return out
def list_bases(self) -> list[dict]:
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
from app import bases
return bases.list_bases(self.s.excel_dir, self._excel_files())
from app import files
return files.registry(self.s.excel_dir, for_admin=True)
# ---- publiczne API ----
def search(self, query: SearchQuery) -> SearchResult:
+5
View File
@@ -6,6 +6,11 @@ pandas>=2.2
openpyxl>=3.1
pyarrow>=18.0
SQLAlchemy>=2.0
# Sterownik Postgresa (DAN-28). Sam SQLAlchemy nie rozmawia z bazą — bez tego
# `postgresql+psycopg://…` wywala się dopiero przy PIERWSZYM połączeniu, już na
# klastrze, komunikatem o braku modułu. [binary] = gotowe koło, bez kompilacji
# libpq w obrazie.
psycopg[binary]>=3.2
pydantic>=2.10
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
+214
View File
@@ -0,0 +1,214 @@
"""Rejestr plików baz: stany, walidacja, archiwizacja (DAN-27).
Testujemy tu RDZEŃ — bez HTTP i bez uprawnień, bo uprawnienia rozstrzyga
prezentacja (patrz services/presentation/tests/test_pliki.py). Tutaj chodzi
o to, żeby żadna operacja nie gubiła pliku i żeby bramka „do użytku tylko po
walidacji" faktycznie trzymała.
"""
import pathlib
import pytest
from app import files
@pytest.fixture()
def root(tmp_path, monkeypatch):
monkeypatch.delenv("FILES_STATE", raising=False)
return tmp_path
def _xlsx(path, rows=3, header=("id", "opis")):
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append(list(header))
for i in range(rows):
ws.append([i, f"treść {i}"])
wb.save(path)
return path
# ── rejestr i stany ──────────────────────────────────────────────────────
def test_a_file_dropped_on_the_share_is_adopted_as_active(root):
"""Baza położona na udziale poza aplikacją (np. przez NFS) ma działać —
tak było przed DAN-27 i tak ma zostać. Plik WGRANY EKRANEM to inna sprawa:
ten wymaga świadomego włączenia (patrz test niżej)."""
_xlsx(root / "baza.xlsx")
entry = files.registry(root)[0]
assert entry["status"] == files.ACTIVE
assert entry["in_use"] is True
def test_only_active_files_reach_the_search(root):
_xlsx(root / "a.xlsx")
_xlsx(root / "b.xlsx")
files.registry(root) # przyjęcie zastanych
files.set_status(root, "b.xlsx", files.READY) # świadome odstawienie
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["a.xlsx"]
files.set_status(root, "b.xlsx", files.ACTIVE)
assert len(files.usable_paths(root)) == 2
def test_state_survives_a_restart(root):
"""Stan jest KLIKANY, więc musi być trwały — inaczej restart poda po cichu
przywracałby bazy wyłączone świadomie."""
_xlsx(root / "a.xlsx")
files.set_status(root, "a.xlsx", files.ACTIVE)
assert files.state_path(root).exists()
assert files.registry(root)[0]["in_use"] is True
# ── archiwizacja ─────────────────────────────────────────────────────────
def test_archiving_freezes_the_file_but_never_removes_it(root):
"""To jest najdalej idąca operacja osoby wgrywającej dane: plik ZOSTAJE."""
p = _xlsx(root / "stara.xlsx")
files.set_status(root, "stara.xlsx", files.ACTIVE)
files.set_status(root, "stara.xlsx", files.ARCHIVED, by="dane")
entry = files.registry(root)[0]
assert p.exists(), "plik zniknął z dysku — archiwizacja ma go zachować"
assert entry["status"] == files.ARCHIVED
assert entry["in_use"] is False
assert entry["archived_at"], "brak znacznika czasu archiwizacji"
def test_archived_file_cannot_slip_back_into_use_by_itself(root):
_xlsx(root / "stara.xlsx")
files.set_status(root, "stara.xlsx", files.ARCHIVED)
assert files.usable_paths(root) == []
# ── walidacja: bramka do użytku ──────────────────────────────────────────
def test_upload_keeps_a_file_that_fails_validation(root):
"""Rzecz najważniejsza: wgranego pliku NIE TRACIMY, choćby nie przeszedł."""
files.set_rules(root, {"extensions": [".xlsx"]})
out = files.store_upload(root, "notatka.txt", "to nie jest baza".encode("utf-8"), by="dane")
assert out["accepted"] is False
assert (root / out["path"]).exists(), "plik odrzucony zniknął z dysku"
admin_view = files.registry(root, for_admin=True)[0]
assert admin_view["status"] == files.QUARANTINE
assert admin_view["rejected_for"], "administrator ma widzieć powód"
def test_a_held_file_is_invisible_without_admin_rights(root):
"""Gdyby plik wstrzymany był widoczny z powodem odrzucenia, każdy wgrywający
poznałby reguły walidacji — a te są narzędziem administratora."""
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
assert files.registry(root, for_admin=False) == []
assert len(files.registry(root, for_admin=True)) == 1
def test_a_held_file_cannot_be_switched_into_use(root):
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
rel = files.registry(root, for_admin=True)[0]["path"]
with pytest.raises(ValueError):
files.set_status(root, rel, files.ACTIVE)
def test_activation_revalidates_and_holds_a_file_that_stopped_qualifying(root):
"""Reguły mogą się zmienić PO wgraniu — bramka sprawdza w chwili włączania,
a nie tylko przy wgrywaniu."""
_xlsx(root / "mala.xlsx", rows=2)
files.set_status(root, "mala.xlsx", files.ACTIVE)
files.set_rules(root, {"min_rows": 500})
files.set_status(root, "mala.xlsx", files.READY)
with pytest.raises(ValueError):
files.set_status(root, "mala.xlsx", files.ACTIVE)
assert (root / "mala.xlsx").exists()
@pytest.mark.parametrize("rule,value,bad", [
("extensions", [".xlsx"], "plik.csv"),
("max_size_mb", 0.000001, "plik.xlsx"),
])
def test_rules_reject_what_they_are_meant_to(root, rule, value, bad):
files.set_rules(root, {rule: value})
out = files.store_upload(root, bad, b"x" * 2048)
assert out["accepted"] is False
def test_required_columns_are_checked_inside_the_workbook(root):
files.set_rules(root, {"required_columns": ["id", "znaczenie"]})
_xlsx(root / "tmp.xlsx", header=("id", "opis"))
why = files.validate(root / "tmp.xlsx", root)
assert why and "znaczenie" in why[0]
def test_duplicate_content_is_rejected_by_hash_not_by_name(root):
files.set_rules(root, {"reject_duplicate_content": True})
data = _xlsx(root / "wzor.xlsx").read_bytes()
first = files.store_upload(root, "pierwsza.xlsx", data)
assert first["accepted"] is True
second = files.store_upload(root, "inna-nazwa.xlsx", data)
assert second["accepted"] is False
def test_upload_never_overwrites_someone_elses_base(root):
files.store_upload(root, "baza.xlsx", _xlsx(root / "w.xlsx").read_bytes())
(root / "w.xlsx").unlink()
files.set_rules(root, {"reject_duplicate_content": False})
out = files.store_upload(root, "baza.xlsx", "inna treść".encode("utf-8"))
assert out["name"] != "baza.xlsx"
assert (root / "baza.xlsx").exists() and (root / out["path"]).exists()
# ── kasowanie ────────────────────────────────────────────────────────────
def test_delete_removes_the_file_and_its_entry(root):
_xlsx(root / "a.xlsx")
files.set_status(root, "a.xlsx", files.ACTIVE)
files.delete(root, "a.xlsx")
assert not (root / "a.xlsx").exists()
assert files.registry(root, for_admin=True) == []
assert files.usable_paths(root) == []
# ── przejście na rejestr nie może wyłączyć wyszukiwania ─────────────────
def test_bases_already_on_the_share_stay_in_use_after_the_switch(root):
"""Dotąd bazy działały domyślnie (wyłączało się je przez DISABLED_BASES).
Po przejściu na rejestr pusty stan oznaczałby, że program nagle niczego nie
znajduje — cicha zmiana zachowania gorsza od awarii, bo wygląda jak pusta baza."""
_xlsx(root / "main_base.xlsx")
_xlsx(root / "zodiac_pl.xlsx")
assert len(files.usable_paths(root)) == 2, "zastane bazy wypadły z wyszukiwania"
assert all(e["in_use"] for e in files.registry(root))
def test_adoption_happens_once_and_respects_later_decisions(root):
"""Po przyjęciu stan jest zapisany, więc świadome odstawienie bazy ZOSTAJE —
kolejny odczyt nie może jej wskrzesić."""
_xlsx(root / "a.xlsx")
_xlsx(root / "b.xlsx")
files.registry(root) # przyjęcie
files.set_status(root, "a.xlsx", files.READY) # świadome odstawienie
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["b.xlsx"]
files.set_status(root, "b.xlsx", files.READY) # odstawiamy wszystko
assert files.usable_paths(root) == [], "pusty wybór został wskrzeszony"
def test_uploaded_files_still_need_an_explicit_switch_on(root):
"""Przyjęcie dotyczy TYLKO baz zastanych. Plik wgrany ekranem ktoś musi
świadomie włączyć — inaczej nowa baza wchodziłaby do wyników sama."""
_xlsx(root / "zastana.xlsx")
files.registry(root)
out = files.store_upload(root, "nowa.xlsx", _xlsx(root / "tmp.xlsx").read_bytes())
assert out["accepted"] is True
names = [pathlib.Path(p).name for p in files.usable_paths(root)]
assert "nowa.xlsx" not in names, "wgrana baza weszła do wyników bez decyzji"
def test_adoption_survives_a_read_only_share(root, monkeypatch):
"""Na udziale tylko do odczytu stanu nie da się zapisać — zachowanie ma
zostać to samo, tylko przyjęcie powtórzy się przy każdym uruchomieniu."""
_xlsx(root / "a.xlsx")
def boom(*a, **kw):
raise OSError("read-only file system")
monkeypatch.setattr(files, "_write_state", boom)
assert len(files.usable_paths(root)) == 1
@@ -0,0 +1,112 @@
"""Rejestr plików a RESZTA warstwy danych — punkty styku (DAN-27).
DLACZEGO OSOBNY PLIK. test_files.py sprawdza sam rejestr w izolacji i przechodził
na zielono, podczas gdy na produkcji leżało wyszukiwanie (500) i lista baz (502).
Rejestr wszedł w miejsce starego mechanizmu włączania baz, więc groźne jest nie
to, co robi w środku, tylko czy MÓWI TYM SAMYM JĘZYKIEM, co jego odbiorcy.
Oba tamte błędy były jednolinijkowe i oba niewidoczne dla testów jednostkowych:
* NameError, bo przepisując `_enabled_files` usunąłem lokalny import `bases`,
* KeyError, bo rejestr oddawał `in_use`, a endpoint /bases czytał `enabled`.
"""
import pathlib
import pytest
@pytest.fixture()
def excel_dir(tmp_path, monkeypatch):
monkeypatch.setenv("EXCEL_DIR", str(tmp_path))
monkeypatch.setenv("CACHE_DIR", str(tmp_path / ".cache"))
monkeypatch.delenv("DISABLED_BASES", raising=False)
monkeypatch.delenv("FILES_STATE", raising=False)
return tmp_path
def _xlsx(path, rows=(("Ma Ari", "odważny"), ("Ve Tau", "zgodny"))):
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["significator", "effect"])
for r in rows:
ws.append(list(r))
wb.save(path)
return path
def _provider(excel_dir):
from app.config import Settings
from app.providers.excel_provider import ExcelDataProvider
return ExcelDataProvider(Settings())
def test_search_does_not_explode_on_the_registry(excel_dir):
"""Regresja: `_enabled_files` wołało bases.disabled_entries() bez importu,
więc KAŻDE wyszukiwanie kończyło się NameError → 500 z warstwy danych."""
from app.models import SearchQuery
_xlsx(excel_dir / "baza.xlsx")
p = _provider(excel_dir)
p.warmup()
out = p.search(SearchQuery(key="significator", value="Ma Ari", exact=False, limit=10))
assert out.total >= 1, "zastana baza nie weszła do wyszukiwania"
def test_search_still_works_with_disabled_bases_set(excel_dir, monkeypatch):
"""DISABLED_BASES zostaje jako awaryjne wyłączenie i ma odsiewać DODATKOWO —
to właśnie ta gałąź kodu wywalała się na braku importu."""
from app.models import SearchQuery
_xlsx(excel_dir / "a.xlsx")
_xlsx(excel_dir / "b.xlsx")
monkeypatch.setenv("DISABLED_BASES", "b.xlsx")
p = _provider(excel_dir)
p.warmup()
out = p.search(SearchQuery(key="significator", value="Ma Ari", exact=False, limit=10))
assert out.total >= 1
def test_registry_speaks_the_language_the_bases_endpoint_reads(excel_dir):
"""Regresja: endpoint /bases liczy `b["enabled"]`, rejestr oddawał `in_use`.
KeyError → 500 z danych → 502 z logiki → „Warstwa logiczna niedostępna"
na ekranie Ustawienia."""
_xlsx(excel_dir / "baza.xlsx")
items = _provider(excel_dir).list_bases()
assert items, "lista baz jest pusta"
for row in items:
for key in ("name", "path", "enabled", "in_use", "size_mb", "modified"):
assert key in row, f"brak pola `{key}` — odbiorca dostanie KeyError"
assert row["enabled"] == row["in_use"], "dwa pola, jedna prawda"
def test_bases_endpoint_answers_end_to_end(excel_dir):
"""Przez TRASĘ, nie przez dostawcę: to ona wywalała się na produkcji."""
_xlsx(excel_dir / "baza.xlsx")
import importlib
from app import main as data_main
importlib.reload(data_main)
body = data_main.bases()
assert body["total"] == 1
assert body["enabled"] == 1, "zastana baza powinna być włączona po adopcji"
assert body["bases"][0]["name"] == "baza.xlsx"
def test_switching_a_base_off_is_visible_in_both_places(excel_dir):
"""Odstawienie bazy ma zniknąć i z wyszukiwania, i z licznika na Ustawieniach."""
from app import files
from app.models import SearchQuery
_xlsx(excel_dir / "baza.xlsx")
p = _provider(excel_dir)
p.warmup()
assert p.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total >= 1
files.set_status(excel_dir, "baza.xlsx", files.READY)
p2 = _provider(excel_dir)
p2.warmup()
assert p2.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total == 0
assert [b["enabled"] for b in p2.list_bases()] == [False]
+68 -2
View File
@@ -15,12 +15,17 @@ from __future__ import annotations
from datetime import datetime
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import swisseph as swe
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)")
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)",
docs_url=None, redoc_url=None, openapi_url=None)
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
@@ -69,6 +74,67 @@ def positions(req: PositionsRequest) -> dict:
return {"engine": "swisseph", "positions": rows}
# Kody systemów domów w Swiss Ephemeris. Nazwy po LEWEJ są nasze — te same,
# których używa houses.SYSTEMS w warstwie logicznej — żeby wołający nie musiał
# znać liter swissepha. Lista celowo pokrywa się 1:1 z naszą: rozjazd oznaczałby,
# że kontrakt parzystości (LOG-28) przestał obejmować część systemów.
_HOUSE_CODES = {
"whole_sign": b"W", "whole_sign_aries": b"N",
"equal": b"E", "equal_mc": b"D",
"porphyry": b"O", "vehlow": b"V", "morinus": b"M",
"regiomontanus": b"R", "campanus": b"C", "alcabitus": b"B",
"topocentric": b"T", "placidus": b"P", "koch": b"K",
}
# Kolejność, w jakiej swe_houses zwraca punkty w tablicy ascmc.
_ASCMC = ("Asc", "MC", "ARMC", "Vertex", "equatorial_asc",
"co_asc_koch", "co_asc_munkasey", "polar_asc")
class HousesRequest(BaseModel):
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
system: str = "whole_sign"
@app.post("/houses")
def houses(req: HousesRequest) -> dict:
"""Cuspy domów i osie policzone przez silnik B — do porównania z własnym.
Domyka kontrakt parzystości (LOG-28) po stronie domów: dotąd obejmował
wyłącznie pozycje obiektów, więc błąd w podziale na domy przechodził przez
porównanie silników niezauważony. Błąd w domach jest CICHY — wykres wygląda
poprawnie, tylko planety siedzą gdzie indziej — więc akurat tu warto mieć
drugie zdanie.
Placidus i Koch nie istnieją powyżej koła podbiegunowego i swisseph zgłasza
tam wyjątek. Oddajemy to jako 422 z czytelnym powodem, a NIE podstawiamy po
cichu innego systemu: cicha podmiana jest nie do wykrycia po stronie
wołającego, a to on ma zdecydować, co z tym zrobić.
"""
code = _HOUSE_CODES.get(req.system)
if code is None:
raise HTTPException(422, f"nieznany system domów: {req.system!r} "
f"(znane: {', '.join(sorted(_HOUSE_CODES))})")
d = req.when_utc
ut_hours = d.hour + d.minute / 60.0 + d.second / 3600.0
jd = swe.julday(d.year, d.month, d.day, ut_hours)
try:
cusps, ascmc = swe.houses(jd, req.lat, req.lon, code)
except Exception as e: # poza dziedziną systemu
raise HTTPException(
422, f"system {req.system!r} nie ma definicji dla φ={req.lat}: {e}") from e
return {
"engine": "swisseph",
"system": req.system,
"cusps": [{"house": i + 1, "longitude": c % 360.0} for i, c in enumerate(cusps)],
"angles": {name: ascmc[i] % 360.0 for i, name in enumerate(_ASCMC)
if i < len(ascmc) and name in ("Asc", "MC", "ARMC", "Vertex")},
}
@app.get("/health")
def health() -> dict:
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
+27
View File
@@ -51,6 +51,33 @@ class DataClient:
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_list(self, for_admin: bool = False) -> dict[str, Any]:
return self._files_post("/files", {"for_admin": for_admin})
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_delete(self, path: str) -> dict[str, Any]:
return self._files_post("/files/delete", {"path": path})
def files_rules(self, rules: dict) -> dict[str, Any]:
return self._files_post("/files/rules", {"rules": rules})
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
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 health(self) -> dict[str, Any]:
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
# nie mają, a nie przechodzi tędy nic z baz.
+9 -1
View File
@@ -60,7 +60,15 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
# z surowych długości/szerokości silnika.
eps = mean_obliquity(Z.julian_day(moment.when_utc))
# ε PRAWDZIWE — to samo, którym liczymy domy, bo pozycje ze Skyfielda są
# POZORNE (uwzględniają nutację). Silnik bez sidereal() nutacji nie poda;
# wtedy zostaje średnie, z jawnym oznaczeniem w wyniku.
if hasattr(engine, "sidereal"):
eps = engine.sidereal(moment)[1]
result["obliquity_kind"] = "true"
else:
eps = mean_obliquity(Z.julian_day(moment.when_utc))
result["obliquity_kind"] = "mean"
result["obliquity"] = round(eps, 6)
for pdict, obj in zip(result["positions"], positions):
dec = declination(obj.longitude, obj.latitude, eps)
+97 -39
View File
@@ -14,6 +14,8 @@ from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
WHOLE_SIGN = "whole_sign"
EQUAL = "equal"
EQUAL_MC = "equal_mc" # równe domy zakotwiczone na MC, nie na Asc
WHOLE_SIGN_ARIES = "whole_sign_aries" # znaki jako domy, ale dom I to ZAWSZE Baran
PORPHYRY = "porphyry"
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania
# iteracyjnego i dochodzą osobno.
@@ -25,14 +27,12 @@ ALCABITUS = "alcabitus"
TOPOCENTRIC = "topocentric"
PLACIDUS = "placidus"
KOCH = "koch"
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris co do zera
# (tests/oracle). TOPOCENTRIC celowo POZA listą: implementacja jest zgodna
# z wyrocznią wszędzie poza otoczeniem bieguna (|φ| ≈ 89,9° przy RAMC 90°/270°),
# gdzie kolejność domów się odwraca i konstrukcja traci sens — jego „biegun"
# atan(tan(89,9°)/3) to już 89,7°. Nie zawężamy dziedziny wyłącznie po to, by
# testy przeszły; system dołączy, gdy będzie poprawny na całej dziedzinie.
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY, VEHLOW, MORINUS,
REGIOMONTANUS, CAMPANUS, ALCABITUS, PLACIDUS, KOCH)
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris
# (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny:
# powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi.
SYSTEMS = (WHOLE_SIGN, WHOLE_SIGN_ARIES, EQUAL, EQUAL_MC, PORPHYRY, VEHLOW,
MORINUS, REGIOMONTANUS, CAMPANUS, ALCABITUS, TOPOCENTRIC,
PLACIDUS, KOCH)
def mean_obliquity(tt_jd: float) -> float:
@@ -289,44 +289,70 @@ def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (PolichPage).
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
_TOPO_POLE_FRACTION = (1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3,
1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3)
# PolichPage: dom pośredni to Ascendent policzony pod własnym „biegunem"
# tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu. Rodzina jest CIĄGŁA:
# przy k=0 (biegun 0, przesunięcie 90°) daje MC, przy k=3 (biegun φ, przesunięcie 0)
# Ascendent, a domy 11 i 12 leżą po drodze.
#
# Cała trudność tego systemu siedziała w wyborze gałęzi — dwa koła wielkie
# przecinają się w dwóch punktach antypodycznych. Heurystyki („po której stronie
# MC", „w łuku kwadrantu", „wschodnia połowa horyzontu", śledzenie ciągłości
# krokami) myliły się na 611% przypadków powyżej ~70°, bo każda z nich rozstrzyga
# LOKALNIE, a przy dużych szerokościach kolejność domów potrafi się odwrócić.
#
# Rozwiązanie: nie wybierać w ogóle. Iloczyn wektorowy zenitu z biegunem ekliptyki
# jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot — dwuznaczność
# wprowadza dopiero atan2. Zostajemy więc w wektorach, a znak ustalamy RAZ, kotwicząc
# rodzinę na MC górującym. Stąd zgodność co do zera na całej dziedzinie, bez iteracji
# i bez zawężania szerokości.
def _asc_under_pole_raw(ramc_deg: float, eps_deg: float, pole_deg: float) -> float:
"""Surowy wzór na ascendent pod zadanym „biegunem", BEZ korekty gałęzi.
Korektę stosuje wywołujący — względem PRAWDZIWEGO MC horoskopu. `compute_asc`
poprawia gałąź względem MC dla PRZESUNIĘTEGO RAMC, co dla cuspu domu jest złym
punktem odniesienia i w okolicach biegunów dawało obrót o 180°."""
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(pole_deg)
return norm360(math.degrees(math.atan2(
math.cos(r), -(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)))))
# Dom → (przesunięcie RAMC [°], ułamek bieguna k/3).
_TOPO_STEP = {10: (-60.0, 1 / 3), 11: (-30.0, 2 / 3), # domy 11, 12
1: (30.0, 2 / 3), 2: (60.0, 1 / 3)} # domy 2, 3
def _cusps_topocentric(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""PolichPage: dom pośredni to ASCENDENT policzony pod własnym „biegunem"
tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu.
"""Ascendenty pod biegunami tan(P) = tan(φ)·k/3, liczone wektorowo.
Domy 5, 6, 8, 9 bierzemy jako OPOZYCJE domów 11, 12, 2, 3 — to nie skrót,
lecz własność konstrukcji: przeciwległe domy leżą na tym samym kole wielkim.
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
konstrukcji „ascendent pod biegunem".
Liczymy tylko domy 11, 12, 2, 3, a 5, 6, 8, 9 bierzemy jako ich OPOZYCJE —
to nie skrót, lecz własność tych systemów: przeciwległe domy leżą na tym samym
kole wielkim, więc ich cuspy są dokładnie antypodyczne."""
tan_lat = math.tan(math.radians(lat))
# Gałąź liczymy względem MC GÓRUJĄCEGO, nie matematycznego: gdy za kołem
# podbiegunowym te dwa się rozjeżdżają, cała czwórka domów pośrednich musi
# obrócić się razem z dziesiątym domem.
konstrukcji „ascendent pod biegunem"."""
# Cuspy topocentrica są poprawne na CAŁEJ dziedzinie (zgodne z wyrocznią co do
# zera), ale powyżej koła podbiegunowego przestają DZIELIĆ OKRĄG: domy nachodzą
# na siebie, bo cusp VII (= I + 180°) wypada przed cuspem VI. Przypisanie planety
# do domu traci wtedy sens — co potwierdza sama wyrocznia, której swe_house_pos
# przeczy tam własnym cuspom (100% zgodności do 62°, 83,9% przy 66°, ok. 50%
# przy 72°). Odmawiamy, zamiast zwracać liczbę bez znaczenia.
#
# To INNY rodzaj granicy niż u Placidusa i Kocha: tam nie istnieją same cuspy,
# tu istnieją, tylko nie tworzą podziału. Próg jest wyprowadzony z warunku
# „dwanaście cuspów sumuje się do 360°", nie dobrany pod wynik testu — i wypada
# na kole podbiegunowym (zmierzone: 100% podziałów do 65°, 78% w pasie 66-67°).
if abs(lat) >= polar_circle(eps):
raise HouseSystemUndefined(
f"φ={lat:.4f}° powyżej koła podbiegunowego ({polar_circle(eps):.4f}° dla "
f"ε={eps:.4f}°): cuspy topocentryczne przestają dzielić okrąg, "
f"domy nachodzą na siebie")
er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
epole = _ecliptic_pole(er)
# Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
# cała rodzina obraca się razem z dziesiątym domem — stąd zwrot iloczynu.
culminating = _culminating_mc(mc, eps, lat)
sign = 1.0 if abs(((culminating - mc + 180.0) % 360.0) - 180.0) > 90.0 else -1.0
out = [0.0] * 12
for i in (10, 11, 1, 2): # domy 11, 12, 2, 3
pole = math.degrees(math.atan(tan_lat * _TOPO_POLE_FRACTION[i]))
lon = _asc_under_pole_raw(ramc + 30.0 * i, eps, pole)
if ((lon - culminating) % 360.0 < 180.0) != _INTERMEDIATE[i]:
lon = norm360(lon + 180.0)
for i, (offset, fraction) in _TOPO_STEP.items():
th = math.radians(ramc + offset)
pole = math.atan(tan_lat * fraction)
zenith = (math.cos(pole) * math.cos(th),
math.cos(pole) * math.sin(th),
math.sin(pole))
v = _cross(zenith, epole)
lon = _equatorial_to_lon(tuple(sign * x for x in v), er)
out[i] = lon
out[(i + 6) % 12] = norm360(lon + 180.0)
out[0], out[3] = asc, norm360(culminating + 180.0)
@@ -451,6 +477,14 @@ def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]:
mc = compute_mc(ramc, eps)
if system in (WHOLE_SIGN, EQUAL, PORPHYRY):
return cusps(asc, mc, system)
if system == WHOLE_SIGN_ARIES:
# Znaki jako domy, ale numeracja rusza od Barana niezależnie od Ascendentu.
# Wariant spotykany w tradycji indyjskiej i w części szkół hellenistycznych.
return [norm360(30.0 * i) for i in range(12)]
if system == EQUAL_MC:
# Równe domy jak `equal`, ale zakotwiczone na MC: dom X zaczyna się
# DOKŁADNIE na MC, więc oś południka wypada na granicy domu, a nie w środku.
return [norm360(mc + 90.0 + 30.0 * i) for i in range(12)]
if system == VEHLOW:
# equal, ale Ascendent leży w ŚRODKU domu I, nie na jego początku
return [norm360(asc - 15.0 + 30.0 * i) for i in range(12)]
@@ -516,14 +550,38 @@ def cusps_detailed(ramc: float, eps: float, lat: float, system: str) -> CuspSet:
FALLBACK_SYSTEM, system, str(e))
def _runs_forward(cusp_list: list[float]) -> bool:
"""Czy domy biegną w stronę rosnących długości ekliptycznych.
Zwykle tak — ale NIE ZAWSZE. Przy dużych szerokościach systemy dzielące koła
wielkie (regiomontanus, campanus, topocentric) mają kolejność ODWRÓCONĄ:
przy φ = 84,3° cusp domu I wypada na 174,2°, a domu II na 165,3°. To nie
jest błąd — wyrocznia zwraca dokładnie te same wartości.
Rozstrzygamy sumą przeskoków „do przodu": dwanaście cuspów dzieli okrąg, więc
idąc we WŁAŚCIWĄ stronę zsumują się do 360°. Idąc pod prąd każdy przeskok
obchodzi koło dookoła i suma wychodzi 11 × 360° = 3960°."""
total = sum((cusp_list[(i + 1) % 12] - cusp_list[i]) % 360.0 for i in range(12))
return abs(total - 360.0) < abs(total - 3960.0)
def assign_house(lon: float, cusp_list: list[float]) -> int:
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
"""Numer domu (1..12), w którym leży dana długość ekliptyczna.
Kierunek liczenia bierzemy z samych cuspów. Zaszycie „zawsze do przodu"
dawało przy |φ| powyżej koła podbiegunowego złe domy dla regiomontanusa,
campanusa i topocentrica — mimo cuspów zgodnych z wyrocznią co do zera.
Błąd był CICHY: wykres wyglądał poprawnie, tylko planety siedziały gdzie
indziej. Sprawdzane wobec swe_house_pos (tests/oracle)."""
lon = norm360(lon)
forward = _runs_forward(cusp_list)
for i in range(12):
start = cusp_list[i]
end = cusp_list[(i + 1) % 12]
span = (end - start) % 360.0
offset = (lon - start) % 360.0
if forward:
span, offset = (end - start) % 360.0, (lon - start) % 360.0
else:
span, offset = (start - end) % 360.0, (start - lon) % 360.0
if offset < span:
return i + 1
return 12
+20 -4
View File
@@ -117,12 +117,28 @@ class SkyfieldEngine(EphemerisEngine):
Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo.
"""
from app.engine.houses import mean_obliquity
t = self.ts.from_datetime(moment.when_utc)
ramc = norm360(t.gast * 15.0 + moment.lon)
eps = mean_obliquity(t.tt)
return ramc, eps
return ramc, true_obliquity(t)
def health(self) -> dict:
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
def true_obliquity(t) -> float:
"""ε PRAWDZIWE (średnie + nutacja w nachyleniu), w stopniach.
MUSI być prawdziwe, nie średnie. RAMC liczymy z `t.gast` — czasu gwiazdowego
POZORNEGO, mierzonego od równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej
samej równonocy ma ε z nutacją; sparowanie GAST z ε średnim miesza dwa układy
odniesienia. Kosztowało to ~3,2″ na cuspach domów i było niewidoczne dla
frameworka wyroczni, bo ten z założenia podaje to samo ε obu stronom
(izoluje samą funkcję domów) — patrz tests/oracle/README.md.
Nutacja z serii IAU 2000A, czyli z tego samego źródła, którego Skyfield
używa do policzenia GAST — dzięki temu oba są spójne z definicji."""
from skyfield.nutationlib import iau2000a, mean_obliquity
mean_arcsec = float(mean_obliquity(t.tdb))
d_eps_arcsec = float(iau2000a(t.tt)[1]) * 1e-7 # jednostki 0,1 µas
return (mean_arcsec + d_eps_arcsec) / 3600.0
+78 -1
View File
@@ -18,7 +18,12 @@ from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse
from app.service import QueryService
app = FastAPI(title="astrololo · warstwa logiczna")
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
app = FastAPI(title="astrololo · warstwa logiczna",
docs_url=None, redoc_url=None, openapi_url=None)
service = QueryService()
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
@@ -121,6 +126,78 @@ def bases() -> dict:
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
# ── zarządzanie plikami baz (DAN-27) — czysty przelot ───────────────────
# Warstwa logiczna niczego tu nie rozstrzyga: uprawnienia zna PREZENTACJA
# (PRE-27), właścicielem plików jest warstwa DANYCH. Ta warstwa tylko przenosi,
# bo prezentacja nie ma prawa rozmawiać z danymi wprost.
class FilesQuery(BaseModel):
for_admin: bool = False
class FileAction(BaseModel):
path: str
status: str = ""
by: str = ""
class FileUpload(BaseModel):
filename: str
content_b64: str
by: str = ""
class RulesUpdate(BaseModel):
rules: dict
def _files_call(fn, *args, **kw) -> dict:
from app.clients.data_client import DataClient
try:
return fn(DataClient(), *args, **kw)
except httpx.HTTPStatusError as e:
# 422 z warstwy danych to ODMOWA MERYTORYCZNA (np. plik nie przeszedł
# walidacji), nie awaria — ma dojść do prezentacji jako 422, żeby dało
# się pokazać powód zamiast „usługa niedostępna".
raise HTTPException(status_code=e.response.status_code,
detail=_detail(e)) from e
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
def _detail(e: httpx.HTTPStatusError) -> str:
try:
return str(e.response.json().get("detail") or e)
except Exception: # noqa: BLE001
return str(e)
@app.post("/files")
def files_list(q: FilesQuery) -> dict:
return _files_call(lambda c: c.files_list(q.for_admin))
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
return _files_call(lambda c: c.files_status(a.path, a.status, a.by))
@app.post("/files/upload")
def files_upload(u: FileUpload) -> dict:
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by))
@app.post("/files/delete")
def files_delete(a: FileAction) -> dict:
return _files_call(lambda c: c.files_delete(a.path))
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return _files_call(lambda c: c.files_rules(u.rules))
@app.post("/chart/synastry")
def chart_synastry(req: SynastryRequest) -> dict:
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
+151 -1
View File
@@ -83,7 +83,8 @@ def test_polar_circle_moves_with_obliquity():
# Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które
# muszą zachodzić także bez swissepha (czyli w każdym środowisku).
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus")
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus",
"topocentric")
@pytest.mark.parametrize("system", EXOTIC)
@@ -196,3 +197,152 @@ def test_polar_circle_boundary_moves_with_the_date():
with pytest.raises(H.HouseSystemUndefined):
H.cusps_for(100.0, 23.4393, lat, "placidus")
assert len(H.cusps_for(100.0, 23.3528, lat, "placidus")) == 12
def test_topocentric_cusps_stay_in_order_between_mc_and_ascendant():
"""Regresja na to, co topocentrica psuło najdłużej — wybór gałęzi.
Cuspy 11 i 12 to punkty CIĄGŁEJ rodziny biegnącej od MC do Ascendentu, więc
czwórka MC → 11 → 12 → Asc leży na okręgu w kolejności, bez wyprzedzania się.
Zły wybór gałęzi przerzuca któryś punkt o 180° i kolejność pęka.
Kierunek celowo NIE jest ustalony: przy dużych szerokościach domy biegną
wstecz (przy φ=79,55° MC=306,8°, a dom 11 to 291,4°) i to jest poprawne —
wyrocznia zwraca tam dokładnie to samo. Sprawdzamy więc, że kolejność trzyma
się w JEDNĄ ze stron, a nie że rośnie."""
def d(a, b):
return H.norm360(b - a)
for lat in (-66.0, -50.0, -20.0, 0.0, 20.0, 50.06, 66.0):
c = H.cusps_for(129.2387, 23.4393, lat, "topocentric")
mc, c11, c12, asc = c[9], c[10], c[11], c[0]
forward = d(mc, c11) + d(c11, c12) + d(c12, asc)
backward = d(c11, mc) + d(c12, c11) + d(asc, c12)
assert (abs(forward - d(mc, asc)) < 1e-9
or abs(backward - d(asc, mc)) < 1e-9), \
f"φ={lat}: MC={mc:.3f} 11={c11:.3f} 12={c12:.3f} Asc={asc:.3f} nie po kolei"
def test_topocentric_refuses_where_its_houses_stop_partitioning_the_circle():
"""Topocentric ma granicę INNEGO rodzaju niż Placidus i Koch.
Same cuspy są poprawne wszędzie (zgodne z wyrocznią co do zera), ale powyżej
koła podbiegunowego przestają dzielić okrąg — cusp VII (= I + 180°) wypada
przed cuspem VI i domy nachodzą na siebie. Przypisanie planety do domu traci
wtedy sens, więc odmawiamy zamiast zwracać liczbę bez znaczenia.
Próg jest WYPROWADZONY z warunku „dwanaście cuspów sumuje się do 360°",
a nie dobrany pod wynik testu — i wypada na kole podbiegunowym."""
for lat in (0.0, 50.06, 66.0):
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
for lat in (69.65, 89.9, -89.9):
with pytest.raises(H.HouseSystemUndefined):
H.cusps_for(100.0, 23.4393, lat, "topocentric")
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
assert cs.is_fallback and cs.notice and "topocentric" in cs.notice
def test_whole_sign_aries_ignores_the_ascendant():
"""Wariant „od Barana": dom I zaczyna się na 0° Barana niezależnie od tego,
co wschodzi. To odróżnia go od zwykłego whole sign."""
for lat in (0.0, 50.06, -33.9):
c = H.cusps_for(100.0, 23.4393, lat, "whole_sign_aries")
assert c == [pytest.approx(30.0 * i) for i in range(12)]
def test_equal_mc_starts_the_tenth_house_exactly_on_the_midheaven():
"""W `equal` MC leży GDZIEŚ w domu X; w `equal_mc` zaczyna go dokładnie."""
ramc, eps, lat = 100.0, 23.4393, 50.0
mc = H.compute_mc(ramc, eps)
c = H.cusps_for(ramc, eps, lat, "equal_mc")
assert c[9] == pytest.approx(mc, abs=1e-9) # dom X rusza na MC
assert c[0] == pytest.approx(H.norm360(mc + 90.0), abs=1e-9)
for i in range(12): # nadal równe 30°
assert H.norm360(c[(i + 1) % 12] - c[i]) == pytest.approx(30.0, abs=1e-9)
# ── nachylenie ekliptyki: prawdziwe, nie średnie ─────────────────────────
def test_sidereal_returns_true_obliquity_not_mean():
"""RAMC liczymy z GAST — czasu gwiazdowego POZORNEGO, mierzonego od
równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej samej równonocy ma ε
Z NUTACJĄ. Sparowanie GAST z ε średnim mieszało dwa układy odniesienia
i przesuwało cuspy domów o ~3″.
Framework wyroczni tego NIE wykryje: z założenia podaje to samo ε obu
stronom, żeby izolować samą funkcję domów. Dlatego sprawdzian jest tutaj,
ze Skyfieldem jako niezależnym autorytetem."""
from datetime import datetime, timezone
from skyfield.api import load
from skyfield.nutationlib import mean_obliquity
from app.engine.models import ChartMoment
from app.engine.skyfield_engine import SkyfieldEngine, true_obliquity
when = datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc)
t = load.timescale().from_datetime(when)
_, eps = SkyfieldEngine().sidereal(ChartMoment(when_utc=when, lat=50.06, lon=19.94))
assert eps == pytest.approx(true_obliquity(t), abs=1e-12)
eps_mean = float(mean_obliquity(t.tdb)) / 3600.0
assert eps != pytest.approx(eps_mean, abs=1e-9), "ε jest średnie — nutacja zgubiona"
# nutacja w nachyleniu nie przekracza ~9,2″; większa różnica to nie nutacja
assert abs(eps - eps_mean) * 3600.0 < 9.3
def test_true_obliquity_stays_in_a_sane_band_across_the_date_range():
"""ε maleje ~0,013°/wiek. Na całym zakresie programu (370 p.n.e. 2270 n.e.)
musi zostać w widełkach 23,223,8° — wyjście oznacza błąd jednostek."""
from datetime import datetime, timezone
from skyfield.api import load
from app.engine.skyfield_engine import true_obliquity
ts = load.timescale()
for year in (1600, 1900, 2000, 2100):
t = ts.from_datetime(datetime(year, 6, 1, tzinfo=timezone.utc))
assert 23.2 < true_obliquity(t) < 23.8, year
# ── przypisanie obiektu do domu ──────────────────────────────────────────
REVERSED = (108.456, 23.4393, -84.324) # tu domy biegną WSTECZ
def test_cusps_can_run_backwards_at_extreme_latitudes():
"""Nie jest to błąd, tylko własność tych systemów — wyrocznia zwraca to samo.
Test istnieje po to, żeby nikt tego „nie naprawił"."""
ramc, eps, lat = REVERSED
c = H.cusps_for(ramc, eps, lat, "regiomontanus")
assert c[1] < c[0] and c[2] < c[1], "kolejność miała być odwrócona"
assert not H._runs_forward(c)
assert H._runs_forward(H.cusps_for(ramc, eps, 50.0, "regiomontanus"))
@pytest.mark.parametrize("system", H.SYSTEMS)
def test_every_point_lands_in_exactly_one_house(system):
"""Cuspy dzielą okrąg bez luk i bez zakładek, więc każdy punkt ma dokładnie
jeden dom, a cusp trafia do domu, który zaczyna. Sprawdzian SAMOSPÓJNOŚCI —
działa bez swissepha, także tam, gdzie sama wyrocznia przeczy sobie
(topocentric powyżej koła podbiegunowego)."""
for ramc, eps, lat in (REVERSED, (100.0, 23.4393, 50.06), (210.33, 23.4393, 72.675)):
try:
cusps = H.cusps_for(ramc, eps, lat, system)
except H.HouseSystemUndefined:
continue
for i, cusp in enumerate(cusps):
assert H.assign_house(cusp, cusps) == i + 1, f"{system}: cusp {i+1}"
seen = {H.assign_house(lon / 4.0, cusps) for lon in range(0, 1440)}
assert seen == set(range(1, 13)), f"{system}: puste albo zdublowane domy {sorted(seen)}"
def test_assign_house_follows_the_cusps_not_a_fixed_direction():
"""Regresja: zaszyte „zawsze do przodu" dawało przy |φ| za kołem podbiegunowym
złe domy dla systemów dzielących koła wielkie — mimo cuspów zgodnych
z wyrocznią co do zera. Błąd był cichy: wykres wyglądał poprawnie."""
ramc, eps, lat = REVERSED
cusps = H.cusps_for(ramc, eps, lat, "regiomontanus")
# 1,7822° leży między cuspem 6 (4,458°) a 7 (354,228°) — patrząc WSTECZ
assert H.assign_house(1.7822, cusps) == 6
+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"]
+213
View File
@@ -0,0 +1,213 @@
"""Magazyn kont zarządzanych z aplikacji (PRE-27).
KONTO ADMINISTRACYJNE NIE LEŻY TUTAJ. Pochodzi z konfiguracji środowiska
(APP_USER/APP_PASSWORD albo APP_USERS) i dlatego NIE DA SIĘ go skasować ani
ograniczyć z poziomu ekranu — nawet przez pomyłkę, nawet spreparowanym żądaniem.
Ekran zarządzania kontami operuje wyłącznie na tym pliku.
Format: JSON `{"users": {"login": {"secret": "scrypt$...", "features": [...],
"note": "...", "created": "ISO"}}}`. Hasła WYŁĄCZNIE jako hash scrypt — ten sam
mechanizm, co dla kont z APP_USERS (security.hash_password), więc nie ma tu
drugiej implementacji do rozjechania.
Zapis jest ATOMOWY (plik tymczasowy + os.replace) i pod blokadą procesu. Plik
leży na NFS, dzieli go kilka replik, a zwykły zapis „w miejscu" po przerwaniu
zostawiłby plik obcięty — czyli utratę WSZYSTKICH kont naraz. os.replace jest
w obrębie jednego katalogu operacją niepodzielną.
"""
from __future__ import annotations
import json
import os
import tempfile
import threading
from datetime import datetime, timezone
from app import features
_lock = threading.Lock()
class AccountsUnavailable(RuntimeError):
"""Pliku kont nie da się odczytać albo zapisać — problem z magazynem.
Osobny wyjątek, bo to NIE JEST błąd danych: konta mogą być w porządku, tylko
katalog jest niedostępny (złe prawa na udziale, nieprzemontowany wolumen,
brak miejsca). Ekran ma wtedy POWIEDZIEĆ, co i gdzie jest nie tak — gołe 500
zostawia administratora z niczym, a to jedyny ekran, z którego może to
naprawić."""
def store_path() -> str:
"""Ścieżka pliku kont.
Domyślnie WŁASNY podkatalog prezentacji na NFS, nie katalog z bazami:
prezentacja nie ma powodu widzieć baz interpretacyjnych, a zamontowanie ich
tutaj obeszłoby bokiem zamknięcie dostępu z DAN-25."""
return os.getenv("ACCOUNTS_FILE", "/app/state/accounts.json")
def _read() -> dict:
path = store_path()
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except FileNotFoundError:
# Pierwsze uruchomienie: pliku jeszcze nie ma i to jest normalne.
return {"users": {}}
except json.JSONDecodeError:
# Plik jest, ale nieczytelny. NIE nadpisujemy go pustym zbiorem —
# to skasowałoby wszystkie konta przy pierwszym zapisie.
raise AccountsUnavailable(
f"Plik kont {path} jest uszkodzony (niepoprawny JSON). "
f"Nic nie zostało zmienione — napraw albo usuń plik ręcznie.") from None
except OSError as e:
raise AccountsUnavailable(
f"Nie mogę odczytać pliku kont {path}: {e.strerror or e}. "
f"Najczęstsza przyczyna: prawa do katalogu na udziale albo "
f"niezamontowany wolumen.") from e
users = data.get("users")
return {"users": users if isinstance(users, dict) else {}}
def _write(data: dict) -> None:
path = store_path()
try:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
except OSError as e:
raise AccountsUnavailable(
f"Nie mogę utworzyć katalogu na plik kont ({os.path.dirname(path)}): "
f"{e.strerror or e}.") from e
# Plik tymczasowy MUSI powstać w tym samym katalogu — os.replace jest
# niepodzielne tylko w obrębie jednego systemu plików.
# mkstemp też musi być w klamrze: przy katalogu tylko do odczytu wywala się
# ONO pierwsze, jeszcze zanim dojdzie do zapisu i podmiany.
try:
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".tmp")
except OSError as e:
raise AccountsUnavailable(
f"Nie mogę zapisać pliku kont {path}: {e.strerror or e}. "
f"Najczęstsza przyczyna: udział zamontowany tylko do odczytu albo "
f"prawa katalogu. Nic nie zostało zmienione.") from e
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except OSError as e:
try:
os.unlink(tmp)
except OSError:
pass
raise AccountsUnavailable(
f"Nie mogę zapisać pliku kont {path}: {e.strerror or e}. "
f"Najczęstsza przyczyna: udział zamontowany tylko do odczytu albo "
f"prawa katalogu. Nic nie zostało zmienione.") from e
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def all_users() -> dict[str, dict]:
"""Konta zarządzane: login → {features, note, created}. BEZ sekretów.
Sekret nie wychodzi z tego modułu — ekran nigdy go nie potrzebuje, a to, czego
nie ma w kontekście szablonu, nie wycieknie do HTML-a przez pomyłkę."""
out = {}
for login, row in _read()["users"].items():
out[login] = {
"features": sorted(features.normalise(row.get("features"))),
"session_version": int(row.get("session_version") or 0),
"note": str(row.get("note") or ""),
"created": str(row.get("created") or ""),
}
return out
def secret_of(login: str) -> str | None:
return (_read()["users"].get(login) or {}).get("secret")
def session_version(login: str) -> int:
"""Numer pokolenia sesji konta (LOG-34).
Podbicie unieważnia WSZYSTKIE otwarte sesje tego konta, także na innych
urządzeniach — bez zmiany hasła. Zmiana hasła robi to samo niejako przy
okazji (zmienia się odcisk w ciasteczku), więc licznik jest potrzebny tylko
do „wyrzuć zewsząd, ale hasło zostaw"."""
try:
return int((_read()["users"].get(login) or {}).get("session_version") or 0)
except (TypeError, ValueError):
return 0
def bump_session(login: str) -> int:
"""Unieważnia wszystkie sesje konta. Podnosi ValueError, gdy konta nie ma."""
with _lock:
data = _read()
row = data["users"].get(login)
if row is None:
raise ValueError(f"Nie ma konta „{login}”.")
row["session_version"] = session_version(login) + 1
_write(data)
return row["session_version"]
def permissions_of(login: str) -> frozenset[str]:
return features.normalise((_read()["users"].get(login) or {}).get("features"))
def exists(login: str) -> bool:
return login in _read()["users"]
def create(login: str, password: str, granted, note: str = "") -> None:
"""Zakłada konto. Podnosi ValueError, gdy login zajęty albo dane puste."""
from app.security import hash_password
login = (login or "").strip()
if not login:
raise ValueError("Login nie może być pusty.")
if not (password or "").strip():
raise ValueError("Hasło nie może być puste.")
with _lock:
data = _read()
if login in data["users"]:
raise ValueError(f"Konto „{login}” już istnieje.")
data["users"][login] = {
"secret": hash_password(password),
"features": sorted(features.normalise(granted)),
"note": (note or "").strip(),
"created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
_write(data)
def update(login: str, granted=None, password: str = "", note: str | None = None) -> None:
"""Zmiana uprawnień, hasła albo opisu. Puste hasło = bez zmiany hasła."""
from app.security import hash_password
with _lock:
data = _read()
row = data["users"].get(login)
if row is None:
raise ValueError(f"Nie ma konta „{login}”.")
if granted is not None:
row["features"] = sorted(features.normalise(granted))
if (password or "").strip():
row["secret"] = hash_password(password)
if note is not None:
row["note"] = note.strip()
_write(data)
def delete(login: str) -> None:
with _lock:
data = _read()
if data["users"].pop(login, None) is None:
raise ValueError(f"Nie ma konta „{login}”.")
_write(data)
+43 -27
View File
@@ -114,8 +114,15 @@ def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
phi = math.radians(180.0 - (lon - asc))
# Co stoi po lewej stronie koła (LOG-05: „warianty 0°Aries / fixed Asc").
ASC_LEFT = "asc"
ARIES_LEFT = "aries"
ORIENTATIONS = ((ASC_LEFT, "Ascendent po lewej"), (ARIES_LEFT, "0° Barana po lewej"))
def _pt(lon: float, ref: float, r: float) -> tuple[float, float]:
"""Punkt na kole dla długości `lon`, przy `ref` po lewej stronie rysunku."""
phi = math.radians(180.0 - (lon - ref))
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
@@ -206,12 +213,19 @@ def _delta(a: float, b: float) -> float:
return abs(((a - b + 180.0) % 360.0) - 180.0)
def render(chart: dict, theme: str = "screen") -> str:
def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> str:
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
orientation — co stoi po LEWEJ stronie koła (LOG-05, warianty rysunku):
'asc' — Ascendent (domyślne; tak rysuje większość szkół zachodnich,
dom I zawsze zaczyna się w tym samym miejscu rysunku),
'aries' — 0° Barana (koło nieruchome względem zodiaku, więc dwa horoskopy
da się porównywać „na oko"; osie wypadają za to gdzie indziej).
Zmienia się WYŁĄCZNIE obrót rysunku — żadna liczba nie jest przeliczana.
"""
T = _THEMES.get(theme, _THEMES["screen"])
# tylko dla druku — na stronie font podaje CSS (.glyph)
@@ -223,6 +237,9 @@ def render(chart: dict, theme: str = "screen") -> str:
return ""
asc = float(angles["Asc"]["decimal"])
# Oś obrotu rysunku. Długości ekliptyczne zostają nietknięte — przesuwamy
# tylko punkt, który ląduje po lewej stronie koła.
ref = 0.0 if orientation == ARIES_LEFT else asc
parts: list[str] = []
# tło i okręgi
@@ -244,8 +261,8 @@ def render(chart: dict, theme: str = "screen") -> str:
continue
color = T["aspects"].get(asp.get("aspect"), T["muted"])
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
ax1, ay1 = _pt(la, asc, R_HUB)
bx1, by1 = _pt(lb, asc, R_HUB)
ax1, ay1 = _pt(la, ref, R_HUB)
bx1, by1 = _pt(lb, ref, R_HUB)
tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} '
f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°')
parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
@@ -253,17 +270,17 @@ def render(chart: dict, theme: str = "screen") -> str:
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
for deg in range(0, 360, 5):
r_in = R_TICK if deg % 10 else R_TICK - 4
x1, y1 = _pt(deg, asc, R_ZOD)
x2, y2 = _pt(deg, asc, r_in)
x1, y1 = _pt(deg, ref, R_ZOD)
x2, y2 = _pt(deg, ref, r_in)
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
for i, sg in enumerate(signs):
b = 30.0 * i
x1, y1 = _pt(b, asc, R_ZOD)
x2, y2 = _pt(b, asc, R_OUT)
x1, y1 = _pt(b, ref, R_ZOD)
x2, y2 = _pt(b, ref, R_OUT)
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
gx, gy = _pt(b + 15.0, asc, R_SIGN)
gx, gy = _pt(b + 15.0, ref, R_SIGN)
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
@@ -271,31 +288,31 @@ def render(chart: dict, theme: str = "screen") -> str:
n = len(cusps)
for i, c in enumerate(cusps):
lon = float(c["decimal"])
x1, y1 = _pt(lon, asc, R_ZOD)
x2, y2 = _pt(lon, asc, R_HUB)
x1, y1 = _pt(lon, ref, R_ZOD)
x2, y2 = _pt(lon, ref, R_HUB)
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
nxt = float(cusps[(i + 1) % n]["decimal"])
span = (nxt - lon) % 360.0 or 360.0
mid = lon + span / 2.0
hx, hy = _pt(mid, asc, R_HNUM)
hx, hy = _pt(mid, ref, R_HNUM)
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
# Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy
# są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer;
# w systemach kwadratowych ta liczba realnie coś mówi.
deg_in_sign = lon % 30.0
if deg_in_sign >= 0.5:
cx1, cy1 = _pt(lon, asc, R_ZOD - 9)
cx1, cy1 = _pt(lon, ref, R_ZOD - 9)
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
# osie: AscDsc i MCIC przez całe koło, wyróżnione akcentem
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
lon_a = float(angles[a]["decimal"])
ax, ay = _pt(lon_a, asc, R_ZOD)
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
ax, ay = _pt(lon_a, ref, R_ZOD)
bx, by = _pt(lon_a + 180.0, ref, R_ZOD)
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
lax, lay = _pt(lon_a, ref, R_AXIS_LABEL)
lbx, lby = _pt(lon_a + 180.0, ref, R_AXIS_LABEL)
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
@@ -310,15 +327,15 @@ def render(chart: dict, theme: str = "screen") -> str:
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
retro = obj.get("direction") == "Rx"
mx1, my1 = _pt(true_lon, asc, R_ZOD)
mx2, my2 = _pt(true_lon, asc, R_MARK)
mx1, my1 = _pt(true_lon, ref, R_ZOD)
mx2, my2 = _pt(true_lon, ref, R_MARK)
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
if _delta(draw_lon, true_lon) > 0.4:
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
lx, ly = _pt(draw_lon, ref, R_PLANET + 12)
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
gx, gy = _pt(draw_lon, asc, R_PLANET)
gx, gy = _pt(draw_lon, ref, R_PLANET)
tip = obj.get("name") or ""
if obj.get("in_sign"):
tip += f" · {obj['in_sign']}"
@@ -332,7 +349,7 @@ def render(chart: dict, theme: str = "screen") -> str:
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
dx, dy = _pt(draw_lon, asc, R_DEG)
dx, dy = _pt(draw_lon, ref, R_DEG)
label = f"{int(true_lon % 30)}" + ("" if retro else "")
parts.append(_text(dx, dy, label, size=8,
fill=T["retro"] if retro else T["muted"]))
@@ -346,10 +363,10 @@ def render(chart: dict, theme: str = "screen") -> str:
if not glyph or lot.get("longitude") is None:
continue
lon = float(lot["longitude"])
mx1, my1 = _pt(lon, asc, R_ZOD)
mx2, my2 = _pt(lon, asc, R_MARK)
mx1, my1 = _pt(lon, ref, R_ZOD)
mx2, my2 = _pt(lon, ref, R_MARK)
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
lx, ly = _pt(lon, asc, R_LOT)
lx, ly = _pt(lon, ref, R_LOT)
tip = lot.get("name") or ""
if lot.get("in_sign"):
tip += f" · {lot['in_sign']}"
@@ -367,7 +384,6 @@ def render(chart: dict, theme: str = "screen") -> str:
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
ASP_CELL = 30.0 # bok komórki siatki aspektów
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
"""Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG.
@@ -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,72 +120,38 @@ 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:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
headers=_auth_headers(), link=_link())
def llm_models(self) -> dict[str, Any]:
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
# ── 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_list(self, for_admin: bool = False) -> dict[str, Any]:
return self._files_post("/files", {"for_admin": for_admin})
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_delete(self, path: str) -> dict[str, Any]:
return self._files_post("/files/delete", {"path": path})
def files_rules(self, rules: dict) -> dict[str, Any]:
return self._files_post("/files/rules", {"rules": rules})
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
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())
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,
@@ -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"},
)
+104
View File
@@ -0,0 +1,104 @@
"""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.features import Feature
from app.podstawa import (app, templates, security, features, accounts_store)
# ---------------- 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_admina=("accounts", "/accounts", "Konta"),
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"})},
)
+164
View File
@@ -0,0 +1,164 @@
"""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.features import Feature
from app.podstawa import (app, templates, logic, features, files_state, _perms, _logic_error)
# ---------------- 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 są
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"},
)
+155
View File
@@ -0,0 +1,155 @@
"""Katalog funkcji i uprawnień (PRE-27).
DWIE ZASADY, KTÓRE RZĄDZĄ TYM MODUŁEM
1. Konto ograniczone ma widzieć program KOMPLETNY — tylko mniejszy. Nic nie może
sugerować, że istnieje coś więcej: żadnych wyszarzonych pozycji, żadnego
„brak uprawnień", żadnego 403. Ścieżka bez uprawnienia zwraca **404**, bo 403
jest samo w sobie informacją, że coś tam jest.
2. Mapa trasa → uprawnienie jest JEDNA i leży tutaj. Rozproszenie jej po
dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka
dziura jest niewidoczna, dopóki ktoś jej nie znajdzie. Test przechodzi po
wszystkich trasach aplikacji i wymaga, żeby każda była tu wymieniona.
PODZIAŁ NA GRUPY. Ekrany odpowiadają zakładkom — to naturalna jednostka, bo
zakładka jest tym, co widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI
wewnątrz ekranów: ktoś może liczyć horoskopy, nie mając dostępu do porównywania
systemów domów ani do generowania tekstu przez model (co kosztuje pieniądze).
"""
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
@dataclass(frozen=True)
class Feature:
key: str
label: str
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
# ── 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 administratora. Osobno od SCREENS, bo ADMIN nie jest
# uprawnieniem do nadania — a wpisane na sztywno w szablonie zostawiały nazwę
# ekranu (i martwy odsyłacz) w produkcie, który tego ekranu nie ma.
NAWIGACJA_ADMINA: list[tuple[str, str, str]] = [] # (klucz, adres, etykieta)
EXTRAS: list[Feature] = []
ALL: tuple[Feature, ...] = ()
BY_KEY: dict[str, Feature] = {}
GRANTABLE: frozenset[str] = frozenset()
ROUTES: dict[tuple[str, str], str | None] = {}
STATIC: dict[str, frozenset[str] | str | None] = {}
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_admina: tuple[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_admina and nawigacja_admina[0] not in {p[0] for p in NAWIGACJA_ADMINA}:
NAWIGACJA_ADMINA.append(nawigacja_admina)
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.
# ── 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: `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
# siedzi na trzech ekranach i konto z którymkolwiek z nich musi ją dostać.
# `None` = każdy zalogowany. Zasób spoza mapy wymaga admina — jak trasa.
_FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
_KOLO = frozenset({"chart", "compile"})
def static_required(name: str) -> frozenset[str] | str | None:
"""Uprawnienie dla zasobu. Nieznany zasób → admin, jak nieznana trasa."""
return STATIC.get(name, ADMIN)
def required(method: str, path: str) -> frozenset[str] | str | None:
"""Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina.
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
if path.startswith("/static/"):
return static_required(path[len("/static/"):])
return ROUTES.get((method.upper(), path), ADMIN)
def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
"""Czy te uprawnienia otwierają tę funkcję.
Zbiór znaczy „wystarczy którekolwiek": jeden zasób bywa wspólny dla kilku
ekranów i nie ma powodu wymagać ich wszystkich naraz."""
if feature is None:
return True
if isinstance(feature, str):
return feature in perms
return bool(perms & feature)
def screens_for(perms: frozenset[str]) -> list[Feature]:
"""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]
def normalise(keys) -> frozenset[str]:
"""Zbiór uprawnień z wejścia formularza: tylko znane klucze, bez ADMIN-a.
ADMIN odsiewamy TUTAJ, a nie w handlerze: konto administracyjne pochodzi
wyłącznie z konfiguracji środowiska, więc żadne wysłanie formularza — nawet
spreparowane — nie może go nadać."""
return frozenset(k for k in (keys or ()) if k in GRANTABLE)
+17
View File
@@ -0,0 +1,17 @@
"""Nazwy stanów pliku bazy — wspólne dla prezentacji i warstwy danych (DAN-27).
Osobny, króciutki moduł, bo prezentacja NIE MOŻE importować warstwy danych (to
inna usługa i inny obraz), a wpisanie tych napisów wprost w handlerach skończyłoby
się literówką, która przejdzie testy i wyjdzie dopiero na produkcji.
"""
ACTIVE = "active"
READY = "ready"
ARCHIVED = "archived"
QUARANTINE = "quarantine"
LABELS = {
ACTIVE: "w użyciu",
READY: "gotowa, odstawiona",
ARCHIVED: "zarchiwizowana",
QUARANTINE: "wstrzymana",
}
+5 -2
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.
@@ -15,12 +15,15 @@ from __future__ import annotations
HOUSE_SYSTEMS: list[tuple[str, str]] = [
("placidus", "Placidus"),
("whole_sign", "Whole Sign"),
("whole_sign_aries", "Whole Sign (od Barana)"),
("koch", "Koch"),
("equal", "Equal"),
("equal", "Equal (od Asc)"),
("equal_mc", "Equal (od MC)"),
("porphyry", "Porphyry"),
("regiomontanus", "Regiomontanus"),
("campanus", "Campanus"),
("alcabitus", "Alcabitus"),
("topocentric", "Topocentric"),
("morinus", "Morinus"),
("vehlow", "Vehlow"),
]
+34 -600
View File
@@ -1,605 +1,39 @@
"""Warstwa PREZENTACJI — usługa HTTP serwująca stronę WWW.
"""Astrololo — ZŁOŻENIE aplikacji z ekranów i modułów opcjonalnych.
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
Ten plik nie zawiera już żadnej trasy. Odpowiada wyłącznie na pytanie „z czego
składa się ten produkt": które ekrany wchodzą i co jest podpięte dodatkowo.
Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych pozycji
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
„/significators".
Import ekranu rejestruje jego trasy, więc lista poniżej JEST definicją produktu.
Inny produkt to inna lista i inny zestaw plików w obrazie — nie ten sam program
z wyłączonymi kawałkami.
"""
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from pathlib import Path
import httpx
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
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
app = FastAPI(title="astrololo · warstwa prezentacji")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
# ── cache-busting statyki (PRE-26) ─────────────────────────────────────────
# 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")
@lru_cache(maxsize=None)
def _asset_version(name: str) -> str:
try:
return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8]
except OSError:
return "0" # brak pliku nie może wywrócić strony
def static_url(name: str) -> str:
return f"/static/{name}?v={_asset_version(name)}"
templates.env.globals["static"] = static_url
# 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
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
def _llm_catalog() -> 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 logic.llm_models()
except httpx.HTTPError:
return {"providers": {}, "defaults": {}}
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 są 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
def _logic_error(e: Exception) -> str:
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
return (
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
)
return f"Warstwa logiczna niedostępna: {e}"
# ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse)
def chart_form(request: Request):
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
)
@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),
):
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}
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=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,
)
# 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 # kosmogram (PRE-12), SVG po stronie serwera
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
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)
# ---------------- 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
):
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (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}
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=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,
)
from app import chartwheel
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
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)
@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ęści od AI (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 SVG→PDF 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"),
)
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"), "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"'})
# ---------------- 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 są
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)
# ---------------- 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)
# ---------------- 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)
# ---------------- 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,
"llm_catalog": _llm_catalog()},
)
@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"),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
"llm_provider": llm_provider, "llm_model": llm_model}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
"llm_catalog": _llm_catalog()}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
if action == "export":
# Tabela robocza do Excela (DAN-23/PRE-10) — pobranie pliku, nie strona.
from fastapi.responses import Response
from app.report_export 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, _report_records(report))
return Response(
content=report_to_xlsx(report),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
)
if action == "prompt":
ctx["prompt_result"] = logic.prompt(
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
provider=llm_provider, model=llm_model,
)
elif action == "horoscope":
ctx["prompt_result"] = logic.horoscope(
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, provider=llm_provider, model=llm_model,
)
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)
# ---------------- 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,
"llm_catalog": _llm_catalog()},
)
@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"),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
"llm_provider": llm_provider, "llm_model": llm_model}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
"llm_catalog": _llm_catalog()}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
if action == "prompt":
ctx["prompt_result"] = logic.prompt(
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, from_date=from_date, to_date=to_date,
provider=llm_provider, model=llm_model,
)
elif action == "horoscope":
ctx["prompt_result"] = logic.horoscope(
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, provider=llm_provider, model=llm_model,
from_date=from_date, to_date=to_date,
)
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)
# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
@app.post("/horoscope/stream")
def horoscope_stream(
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 wyniku.
"""
from fastapi.responses import StreamingResponse
try:
iso_utc, _ = _build_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 logic.horoscope_stream(payload):
try:
event = json.loads(raw)
except ValueError:
continue
if event.get("type") == "result":
html = templates.get_template("_prompt_result.html").render(
prompt_result=event.get("result") or {}
)
event["html"] = html
yield json.dumps(event, ensure_ascii=False) + "\n"
except httpx.HTTPError as e:
yield json.dumps({"type": "error", "message": _logic_error(e)},
ensure_ascii=False) + "\n"
return StreamingResponse(relay(), media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
# ---------------- 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
@app.get("/health")
def health() -> dict:
return {"status": "ok", "layer": "presentation"}
from app import rozszerzenia
from app.podstawa import (_asset_body, _asset_version, _build_utc, _logic_error,
_report_records, app, logic, security, static_url,
templates)
from app.ekrany import ( # noqa: F401 - import rejestruje trasy
horoskop,
interpretacje,
kalendarz,
konta,
logowanie,
lokalizacja,
pliki,
skompiluj,
sygnifikatory,
synastria,
ustawienia,
)
# Nazwy przekazywane dalej dla wygody wołających (testy, uvicorn).
__all__ = ["app", "logic", "templates", "static_url", "_asset_body", "_asset_version"]
# ── moduły opcjonalne ──────────────────────────────────────────────────────
# Na końcu, bo moduł dostaje gotowe zależności, a te muszą już istnieć. Bez
# modułu w obrazie to wywołanie nie robi nic i aplikacja nie ma pojęcia,
# że czegoś brakuje.
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
buduj_utc=_build_utc, blad=_logic_error,
security=security, licz_rekordy=_report_records)
@@ -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")
@@ -0,0 +1,38 @@
/* Generowanie tekstu przez model — dokładany wyłącznie, gdy konto ma
uprawnienie „ai”. Selektory nazywają funkcję, więc arkusz nie może
wchodzić do arkuszy ekranów, na których ta funkcja bywa wyłączona. */
/* Okno postępu przy generowaniu horoskopu */
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
display: flex; align-items: center; justify-content: center;
z-index: 1200; padding: 1rem; }
.progress-overlay[hidden] { display: none; }
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
color: var(--muted); font-size: .9rem; }
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
border: 2px solid var(--line); border-top-color: var(--accent);
animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
max-height: 15rem; overflow-y: auto;
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
.progress-log li { color: var(--ink); white-space: pre-wrap; }
.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 SVG→PDF 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"'})
+275
View File
@@ -0,0 +1,275 @@
"""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 są 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 accounts as accounts_store
from app import rozszerzenia
from app import chartwheel as chartwheel_mod
from app import features
from app import session
from app import files_state
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
są 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"},
)
templates.env.globals["static"] = static_url
templates.env.globals["nav_admin"] = lambda: features.NAWIGACJA_ADMINA
# 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
templates.env.globals["STATUS_LABELS"] = files_state.LABELS
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)
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 ją w wyniku. Granicą jest ta funkcja, nie szablon.
Wartości zastępcze są 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 ją 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 są 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"}
+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 są ODKRYWANE: most przechodzi po podkatalogach
`app/moduly/` i pyta każdy, co wnosi.
Gdy nie ma żadnego, wszystkie odpowiedzi są 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")
+197 -27
View File
@@ -1,7 +1,7 @@
"""Ochrona dostępu do aplikacji (LOG-32).
Rdzeniem produktu są 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 tę 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
@@ -42,9 +42,10 @@ import os
import secrets
import time
from collections import deque
from dataclasses import dataclass
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
log = logging.getLogger("astrololo.security")
@@ -113,12 +114,35 @@ 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"}
PUBLIC_PATHS = frozenset({"/health"})
PUBLIC_PREFIXES = ("/static/",)
# /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]] = {}
@@ -131,23 +155,109 @@ def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
def authenticate(header: str | None) -> str | None:
"""Nagłówek Basic → LOGIN zalogowanej osoby albo None.
@dataclass(frozen=True)
class Principal:
"""Kto się zalogował i co wolno mu zobaczyć."""
login: str
permissions: frozenset[str]
is_admin: bool
def _admin_permissions() -> frozenset[str]:
"""Administrator widzi wszystko — z definicji, bez wypisywania listy ręcznie.
Liczone z katalogu, więc dopisanie funkcji NIE wymaga pamiętania o koncie
administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja byłaby
niewidoczna dla jedynej osoby, która ma ją komu nadać."""
from app import features
return frozenset(features.GRANTABLE) | {features.ADMIN}
def _admin_spec(login: str) -> str | None:
"""Sekret konta administracyjnego z KONFIGURACJI ŚRODOWISKA albo None.
Sprawdzane ZAWSZE PIERWSZE i bez dotykania pliku kont — to jest cała
separacja konta administracyjnego (LOG-34):
* konto z pliku o tym samym loginie nie przesłoni administratora, bo do
pliku w ogóle nie zaglądamy, gdy login pasuje do konfiguracji,
* administrator zaloguje się nawet wtedy, gdy plik kont jest uszkodzony
albo nieczytelny — czyli w jedynej sytuacji, w której ktoś musi wejść,
żeby to naprawić. Trzymanie jego stanu w tym samym pliku dawałoby
zakleszczenie: nie da się naprawić, bo nie da się wejść.
"""
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 = _admin_spec(login)
if spec is not None:
return Principal(login, _admin_permissions(), True) if _secret_matches(spec, password) else None
from app import accounts as store
Zwracamy login (nie bool), bo dziennik audytowy musi wiedzieć KTO — to sedno
PRE-17. Nieznany login i złe hasło traktujemy tak samo (bez podpowiadania,
które konto istnieje)."""
if not header or not header.lower().startswith("basic "):
return None
try:
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
user, _, password = raw.partition(":")
except (binascii.Error, UnicodeDecodeError, IndexError):
zapisany = store.secret_of(login)
except store.AccountsUnavailable:
# Magazyn kont niedostępny — konta zarządzane nie mogą się zalogować,
# ale administrator (wyżej) już przeszedł, więc jest kto to naprawi.
return None
spec = accounts().get(user)
if spec is None:
if zapisany is None or not _secret_matches(zapisany, password):
return None
return user if _secret_matches(spec, password) else None
return Principal(login, store.permissions_of(login), 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 accounts as store
from app import session
spec = _admin_spec(login)
if spec is not None:
return session.issue(login, session.fingerprint(spec), 0)
return session.issue(login, session.fingerprint(store.secret_of(login) or ""),
store.session_version(login))
def principal(request: Request) -> Principal | None:
"""Ciasteczko → kto to jest i co mu wolno. None = nie wpuszczać."""
from app import session
dane = session.read(request.cookies.get(session.COOKIE))
if dane is None:
return None
login = dane["login"]
spec = _admin_spec(login)
if spec is not None:
# Zmiana APP_PASSWORD ubija sesje administratora — to jest jego
# odpowiednik „wyloguj mnie ze wszystkich urządzeń".
if session.fingerprint(spec) != dane["odcisk"]:
return None
return Principal(login, _admin_permissions(), True)
from app import accounts as store
try:
zapisany = store.secret_of(login)
wersja = store.session_version(login)
except store.AccountsUnavailable:
return None
if zapisany is None or session.fingerprint(zapisany) != dane["odcisk"]:
return None
if wersja != dane["wersja"]:
return None
return Principal(login, store.permissions_of(login), False)
def client_ip(request: Request) -> str:
@@ -225,6 +335,22 @@ def audit_records(request: Request, count: int) -> None:
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(
@@ -235,8 +361,20 @@ def _audit(request: Request, user: str, status: int, ms: float) -> None:
def install(app) -> None:
"""Podpina ochronę pod wszystkie ścieżki poza /health i /static."""
"""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 (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA "
@@ -264,16 +402,48 @@ def install(app) -> None:
status_code=429, headers={"Retry-After": "60"},
)
user = ""
from app import features
user, who, odswiez = "", None, None
if auth_enabled():
user = authenticate(request.headers.get("authorization")) or ""
if not user:
_audit(request, "-", 401, (time.monotonic() - started) * 1000)
return HTMLResponse(
"<h1>401 — wymagane logowanie</h1>", status_code=401,
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
)
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("", _admin_permissions(), True)
request.state.user = user
request.state.principal = who
# 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
+183
View File
@@ -0,0 +1,183 @@
"""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
COOKIE = "astrololo_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:
"""Ustawienia 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": "/"}
+87
View File
@@ -0,0 +1,87 @@
/* Powłoka strony i ekran logowania — JEDYNY arkusz publiczny.
Nie wolno tu wstawić niczego, co nazywa funkcję: ten plik pobiera
każdy, także niezalogowany, bo potrzebuje go ekran logowania. */
:root {
--bg: #0f1020;
--panel: #1a1c33;
--ink: #e8e8f0;
--muted: #9aa0c0;
--accent: #8b7bf0;
--line: #2a2d4a;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; justify-content: center; padding: 3rem 1rem;
}
main { width: 100%; max-width: 880px; }
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
.topbar nav { display: flex; gap: .25rem; }
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
.topbar nav a:hover { color: var(--ink); }
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
input, select, button {
font: inherit; padding: .6rem .75rem; border-radius: 8px;
border: 1px solid var(--line); background: #12132a; color: var(--ink);
}
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
button:hover { filter: brightness(1.1); }
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
/* Fallback systemu domów. Celowo NIE jest to dyskretny „muted" — po fallbacku
kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
podział domów pochodzi z innego systemu, niż zamówiono. */
.notice-warn {
margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
border: 1px solid var(--warn-border, #b8860b);
border-left-width: 4px;
border-radius: 4px;
background: var(--warn-bg, rgba(184, 134, 11, .10));
color: var(--warn-fg, inherit);
}
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
tr:last-child td { border-bottom: none; }
.muted { color: var(--muted); }
.small { font-size: .85rem; }
.nowrap { white-space: nowrap; }
/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */
.card {
margin-top: 1rem; padding: 1rem 1.25rem;
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
}
.card .actions { margin-top: .9rem; }
/* Potwierdzenie operacji. Odróżnia się od .notice-warn, bo tamto ostrzega,
a to tylko potwierdza — pomylenie ich uczy ignorować oba. */
.done-note {
margin: 1rem 0; padding: .6rem .9rem; font-size: .9rem;
border: 1px solid var(--line); border-left: 4px solid var(--accent);
border-radius: 4px; background: rgba(255, 255, 255, .03);
}
/* ── wyjście z aplikacji (LOG-32) ───────────────────────────────────── */
/* Odsunięte od zakładek i wyglądające inaczej niż one: to czynność, nie miejsce,
a pomylenie go z zakładką kosztuje przypadkowe wylogowania. Odstęp robimy
marginesem, nie rozpychaczem — `nav` ma szerokość treści, więc nie ma czego
rozpychać. */
.whoami { color: var(--muted); font-size: .85rem; align-self: center;
margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); }
/* Wylogowanie idzie POST-em, więc jest przyciskiem — ale ma wyglądać jak
reszta paska, nie jak przycisk akcji w formularzu. */
.wyloguj-form { display: inline; margin: 0; padding: 0; background: none; border: none; }
button.wyloguj {
background: none; border: 1px solid transparent; color: var(--muted);
font: inherit; padding: .4rem .8rem; border-radius: 8px; cursor: pointer;
}
button.wyloguj:hover { color: var(--ink); }
/* Ekran logowania — wąski, bez nawigacji: nie ma dokąd iść przed zalogowaniem. */
.login-main { max-width: 26rem; }
.login-card label { display: block; margin-top: .75rem; }
.login-card input { width: 100%; }
.login-note { margin-top: 1.25rem; }
+5 -3
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
@@ -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) {
+1 -1
View File
@@ -23,7 +23,7 @@
// 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]' },
@@ -0,0 +1,24 @@
/* Arkusz ekranu „admin” — dokładany tylko dla konta z tym uprawnieniem. */
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.loc { margin-top: .8rem; }
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; }
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
kolumn jest po prostu mniej. */
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
gap: .35rem 1rem; align-items: start; }
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
.opts .hint { font-style: normal; opacity: .75; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; }
.account-note input { width: 100%; }
button.danger { background: #8b2f2f; }
@@ -0,0 +1,119 @@
/* Arkusz ekranu „chart” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.loc { margin-top: .8rem; }
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
2) font-variant-emoji:text — nowsza własność CSS,
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
"DejaVu Sans", sans-serif; }
td.glyph { color: var(--accent); white-space: nowrap; }
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
.wheel-fig figcaption { margin-top: .4rem; }
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
svg.wheel text.glyph { font-size: inherit; }
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
.aspectarian-fig figcaption { margin-top: .4rem; }
svg.aspectarian text.glyph { font-size: inherit; }
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
zostać na wierzchu. */
.wheel-fig svg.wheel { cursor: zoom-in; }
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.wheel-fig.zoomed {
position: fixed; inset: 0; z-index: 1100; margin: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: .5rem; padding: 1rem; background: var(--bg);
}
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
.wheel-fig.zoomed svg.wheel {
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
}
body.wheel-zoom-open { overflow: hidden; }
/* nie przewijaj treści pod spodem */
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
(1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
.wheel-tip {
position: fixed; z-index: 1150; pointer-events: none;
max-width: 22rem; padding: .4rem .6rem;
background: var(--panel); color: var(--fg, inherit);
border: 1px solid var(--line); border-radius: 8px;
font-size: .85rem; line-height: 1.35;
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
}
.wheel-tip[hidden] { display: none; }
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
kolumn jest po prostu mniej. */
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
gap: .35rem 1rem; align-items: start; }
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
.opts .hint { font-style: normal; opacity: .75; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
się sama tabela, nigdy strona. */
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
.table-scroll > table { margin-top: 0; border-radius: 0; }
.table-scroll th, .table-scroll td { white-space: nowrap; }
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
nie wiadomo, który to wiersz. */
.table-scroll th:first-child, .table-scroll td:first-child {
position: sticky; left: 0; background: var(--panel); z-index: 1;
box-shadow: 1px 0 0 var(--line);
}
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
.geo { margin-top: .9rem; }
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
.geo-results li:last-child { border-bottom: none; }
.geo-results li:hover { background: var(--panel); color: #fff; }
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
.geo-map[hidden] { display: none; }
.geo-attrib { margin: .4rem 0 0; }
.geo-attrib a { color: var(--accent); }
.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; }
button.toggle {
background: none; border: 1px solid var(--line); border-radius: 50%;
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
}
button.toggle.on { color: var(--accent); border-color: var(--accent); }
button.toggle.off { color: var(--muted); }
@@ -0,0 +1,114 @@
/* Arkusz ekranu „compile” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.loc { margin-top: .8rem; }
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
2) font-variant-emoji:text — nowsza własność CSS,
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
"DejaVu Sans", sans-serif; }
td.glyph { color: var(--accent); white-space: nowrap; }
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
.wheel-fig figcaption { margin-top: .4rem; }
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
svg.wheel text.glyph { font-size: inherit; }
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
.aspectarian-fig figcaption { margin-top: .4rem; }
svg.aspectarian text.glyph { font-size: inherit; }
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
zostać na wierzchu. */
.wheel-fig svg.wheel { cursor: zoom-in; }
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.wheel-fig.zoomed {
position: fixed; inset: 0; z-index: 1100; margin: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: .5rem; padding: 1rem; background: var(--bg);
}
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
.wheel-fig.zoomed svg.wheel {
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
}
body.wheel-zoom-open { overflow: hidden; }
/* nie przewijaj treści pod spodem */
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
(1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
.wheel-tip {
position: fixed; z-index: 1150; pointer-events: none;
max-width: 22rem; padding: .4rem .6rem;
background: var(--panel); color: var(--fg, inherit);
border: 1px solid var(--line); border-radius: 8px;
font-size: .85rem; line-height: 1.35;
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
}
.wheel-tip[hidden] { display: none; }
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
kolumn jest po prostu mniej. */
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
gap: .35rem 1rem; align-items: start; }
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
.opts .hint { font-style: normal; opacity: .75; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
się sama tabela, nigdy strona. */
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
.table-scroll > table { margin-top: 0; border-radius: 0; }
.table-scroll th, .table-scroll td { white-space: nowrap; }
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
nie wiadomo, który to wiersz. */
.table-scroll th:first-child, .table-scroll td:first-child {
position: sticky; left: 0; background: var(--panel); z-index: 1;
box-shadow: 1px 0 0 var(--line);
}
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
.readiness li { padding: .2rem 0; }
.readiness .rdy-ok { color: #7fd18b; }
.readiness .rdy-missing { color: #e0c060; }
.report-head { margin: 1.5rem 0 .5rem; }
.report-head h2 { margin: 0; font-size: 1.4rem; }
/* 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; }
button.toggle {
background: none; border: 1px solid var(--line); border-radius: 50%;
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
}
button.toggle.on { color: var(--accent); border-color: var(--accent); }
button.toggle.off { color: var(--muted); }
@@ -0,0 +1,36 @@
/* Arkusz ekranu „files” — dokładany tylko dla konta z tym uprawnieniem. */
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
się sama tabela, nigdy strona. */
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
.table-scroll > table { margin-top: 0; border-radius: 0; }
.table-scroll th, .table-scroll td { white-space: nowrap; }
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
nie wiadomo, który to wiersz. */
.table-scroll th:first-child, .table-scroll td:first-child {
position: sticky; left: 0; background: var(--panel); z-index: 1;
box-shadow: 1px 0 0 var(--line);
}
button.danger { background: #8b2f2f; }
/* ── ekran plików (DAN-27) ──────────────────────────────────────────── */
form.inline { display: inline; }
td.ops form.inline + form.inline { margin-left: .4rem; }
button.toggle {
background: none; border: 1px solid var(--line); border-radius: 50%;
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
}
button.toggle.on { color: var(--accent); border-color: var(--accent); }
button.toggle.off { color: var(--muted); }
tr.row-archived td { opacity: .55; }
/* Wstrzymane widzi tylko administrator — dla reszty tych wierszy nie ma
w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */
tr.row-held td { background: rgba(184, 134, 11, .07); }
@@ -0,0 +1,36 @@
/* Arkusz ekranu „interpret” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
td.retro { color: #ff9b6a; font-weight: 600; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.sig-item { margin-top: 1.25rem; }
.sig-head { padding: .5rem 0; }
.samples { margin-top: .3rem; }
.samples td { font-size: .92rem; vertical-align: top; }
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
.facet { margin: .4rem 0 .6rem 1rem; }
.facet-head { color: var(--ink); font-size: .9rem; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
.geo { margin-top: .9rem; }
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
.geo-results li:last-child { border-bottom: none; }
.geo-results li:hover { background: var(--panel); color: #fff; }
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
.geo-map[hidden] { display: none; }
.geo-attrib { margin: .4rem 0 0; }
.geo-attrib a { color: var(--accent); }
.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; }
@@ -0,0 +1,7 @@
/* Arkusz ekranu „settings” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
@@ -0,0 +1,8 @@
/* Arkusz ekranu „significators” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.empty { color: var(--muted); }
@@ -0,0 +1,23 @@
/* Arkusz ekranu „synastry” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
2) font-variant-emoji:text — nowsza własność CSS,
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
"DejaVu Sans", sans-serif; }
td.glyph { color: var(--accent); white-space: nowrap; }
svg.aspectarian text.glyph { font-size: inherit; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
@@ -0,0 +1,33 @@
/* Arkusz ekranu „timeline” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
td.retro { color: #ff9b6a; font-weight: 600; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.sig-item { margin-top: 1.25rem; }
.sig-head { padding: .5rem 0; }
.samples { margin-top: .3rem; }
.samples td { font-size: .92rem; vertical-align: top; }
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
.geo { margin-top: .9rem; }
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
.geo-results li:last-child { border-bottom: none; }
.geo-results li:hover { background: var(--panel); color: #fff; }
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
.geo-map[hidden] { display: none; }
.geo-attrib { margin: .4rem 0 0; }
.geo-attrib a { color: var(--accent); }
.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; }
-191
View File
@@ -1,191 +0,0 @@
:root {
--bg: #0f1020;
--panel: #1a1c33;
--ink: #e8e8f0;
--muted: #9aa0c0;
--accent: #8b7bf0;
--line: #2a2d4a;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; justify-content: center; padding: 3rem 1rem;
}
main { width: 100%; max-width: 880px; }
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
.topbar nav { display: flex; gap: .25rem; }
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
.topbar nav a:hover { color: var(--ink); }
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.loc { margin-top: .8rem; }
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
2) font-variant-emoji:text — nowsza własność CSS,
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
"DejaVu Sans", sans-serif; }
td.glyph { color: var(--accent); white-space: nowrap; }
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
.wheel-fig figcaption { margin-top: .4rem; }
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
svg.wheel text.glyph { font-size: inherit; }
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
.aspectarian-fig figcaption { margin-top: .4rem; }
svg.aspectarian text.glyph { font-size: inherit; }
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
zostać na wierzchu. */
.wheel-fig svg.wheel { cursor: zoom-in; }
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.wheel-fig.zoomed {
position: fixed; inset: 0; z-index: 1100; margin: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: .5rem; padding: 1rem; background: var(--bg);
}
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
.wheel-fig.zoomed svg.wheel {
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
}
body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
.row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; }
input, select, button {
font: inherit; padding: .6rem .75rem; border-radius: 8px;
border: 1px solid var(--line); background: #12132a; color: var(--ink);
}
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
button:hover { filter: brightness(1.1); }
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
.opts input[type=number] { width: 5rem; }
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
/* Fallback systemu domów. Celowo NIE jest to dyskretny „muted" — po fallbacku
kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
podział domów pochodzi z innego systemu, niż zamówiono. */
.house-warning {
margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
border: 1px solid var(--warn-border, #b8860b);
border-left-width: 4px;
border-radius: 4px;
background: var(--warn-bg, rgba(184, 134, 11, .10));
color: var(--warn-fg, inherit);
}
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.empty { color: var(--muted); }
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
tr:last-child td { border-bottom: none; }
.muted { color: var(--muted); }
.small { font-size: .85rem; }
.nowrap { white-space: nowrap; }
.sig-item { margin-top: 1.25rem; }
.sig-head { padding: .5rem 0; }
.samples { margin-top: .3rem; }
.samples td { font-size: .92rem; vertical-align: top; }
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
.facet { margin: .4rem 0 .6rem 1rem; }
.facet-head { color: var(--ink); font-size: .9rem; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
.geo { margin-top: .9rem; }
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
.geo-results li:last-child { border-bottom: none; }
.geo-results li:hover { background: var(--panel); color: #fff; }
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
.geo-map[hidden] { display: none; }
.geo-attrib { margin: .4rem 0 0; }
.geo-attrib a { color: var(--accent); }
.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; }
/* Okno postępu przy generowaniu horoskopu */
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
display: flex; align-items: center; justify-content: center;
z-index: 1200; padding: 1rem; }
.progress-overlay[hidden] { display: none; }
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
color: var(--muted); font-size: .9rem; }
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
border: 2px solid var(--line); border-top-color: var(--accent);
animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
max-height: 15rem; overflow-y: auto;
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
.progress-log li { color: var(--ink); white-space: pre-wrap; }
.progress-log li.log-ok { color: #7fd18b; }
.progress-log li.log-warn { color: #e0c060; }
.progress-log li.log-err { color: #ef6b6b; }
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
.readiness li { padding: .2rem 0; }
.readiness .rdy-ok { color: #7fd18b; }
.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,
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; }
@@ -0,0 +1,97 @@
// Dymki na kosmogramie — własne, zamiast natywnych z <title> (PRE-12/PRE-25).
//
// DLACZEGO NIE ZOSTAWIAMY NATYWNYCH. Rysunek niesie <title> przy każdym obiekcie
// i każdej linii aspektu i to wystarczało, dopóki koło było statyczne. Po dodaniu
// powiększania na pełne okno dymki przestały się pokazywać — a niezależnie od tego,
// która reguła przeglądarki je tłumi, natywny dymek i tak jest tu kiepskim
// narzędziem: pojawia się po sekundzie zwłoki, nie da się go stylować, nie działa
// na dotyku i nie ma go czym wywołać z klawiatury.
//
// Sprawdzone w przeglądarce (1280×900, komplet 13 systemów): elementy z <title>
// SĄ osiągalne kursorem — 19 z 20 trafień w hit-teście — więc nic ich nie zasłania
// i problem nie leży w geometrii ani w pointer-events.
//
// <title> ZOSTAJE w rysunku: to nazwa dostępna dla czytników ekranu, a PDF składa
// się po stronie serwera, więc ta ścieżka jest nietknięta. Na czas najechania
// odpinamy go i wieszamy z powrotem po zejściu kursora — dzięki temu nigdy nie
// widać dwóch dymków naraz, gdyby przeglądarka jednak swój pokazała.
(function () {
'use strict';
var OFFSET = 14; // odsunięcie od kursora, żeby dymek nie siedział pod nim
var stash = null; // { owner, node } — odpięty <title> do przywieszenia
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
var wheels = document.querySelectorAll('svg.wheel');
if (!wheels.length) return;
var tip = document.createElement('div');
tip.className = 'wheel-tip';
tip.setAttribute('role', 'tooltip');
tip.hidden = true;
document.body.appendChild(tip);
function restore() {
if (!stash) return;
stash.owner.appendChild(stash.node);
stash = null;
}
function hide() {
tip.hidden = true;
restore();
}
// Element z własnym <title>, licząc od celu zdarzenia w górę. Aspekty to
// cienkie linie, a glify siedzą w <text> — oba trzymają <title> jako dziecko.
function owner(el, root) {
while (el && el !== root) {
if (el.querySelector && el.querySelector(':scope > title')) return el;
el = el.parentNode;
}
return null;
}
function place(x, y) {
// Ustawiamy najpierw, potem mierzymy — inaczej nie znamy rozmiaru dymka.
tip.style.left = '0px';
tip.style.top = '0px';
var w = tip.offsetWidth, h = tip.offsetHeight;
var left = x + OFFSET, top = y + OFFSET;
if (left + w > window.innerWidth - 4) left = x - OFFSET - w; // odbij w lewo
if (top + h > window.innerHeight - 4) top = y - OFFSET - h; // odbij w górę
tip.style.left = Math.max(4, left) + 'px';
tip.style.top = Math.max(4, top) + 'px';
}
Array.prototype.forEach.call(wheels, function (svg) {
svg.addEventListener('mousemove', function (e) {
var el = owner(e.target, svg);
if (!el) { hide(); return; }
if (!stash || stash.owner !== el) {
restore();
var node = el.querySelector(':scope > title');
tip.textContent = node.textContent;
stash = { owner: el, node: node };
el.removeChild(node);
tip.hidden = false;
}
place(e.clientX, e.clientY);
});
svg.addEventListener('mouseleave', hide);
});
// Dymek nie może przeżyć przewinięcia ani powiększenia — zostałby wiszący
// w miejscu, w którym nic już nie ma.
window.addEventListener('scroll', hide, true);
window.addEventListener('resize', hide);
document.addEventListener('click', hide, true);
});
})();
@@ -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,21 +1,38 @@
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i „Skompiluj" (/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') %}
<div class="opts">
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
licz stacje planet (wolniejsze)</label>
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
</div>
<div class="opts" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
{% endif %}
{% if can(request, 'houses_compare') %}
<div class="opts systems" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
{% set chosen = form.house_systems or [] %}
<span class="muted small">Porównaj systemy domów:</span>
<span class="muted small opts-label">Porównaj systemy domów
<em class="hint">— gwiazdka: nie działa za kołem podbiegunowym (ok. 66,5°); tam policzymy Porphyry i wyraźnie o tym napiszemy</em>
</span>
{% for value, name in HOUSE_SYSTEMS %}
<label><input type="checkbox" name="house_systems" value="{{ value }}" {{ 'checked' if value in chosen else '' }}> {{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</label>
{% endfor %}
</div>
{% endif %}
{% if can(request, 'houses_compare') %}
<div class="opts" title="Zmienia wyłącznie OBRÓT rysunku — żadna liczba nie jest przeliczana. „Ascendent po lewej” to tradycja zachodnia: dom I zawsze zaczyna się w tym samym miejscu. „0° Barana po lewej” unieruchamia koło względem zodiaku, dzięki czemu dwa horoskopy da się porównywać na oko.">
{% set wo = form.wheel_orientation or 'asc' %}
<span class="muted small">Kosmogram — po lewej stronie:</span>
{% for value, name in WHEEL_ORIENTATIONS %}
<label><input type="radio" name="wheel_orientation" value="{{ value }}" {{ 'checked' if wo == value else '' }}> {{ name }}</label>
{% endfor %}
</div>
{% endif %}
{% if can(request, 'advanced_calc') %}
<div class="opts" title="Orb = dopuszczalne odchylenie od dokładnego kąta aspektu. „Bonus świateł" powiększa orb dla aspektów ze Słońcem/Księżycem. Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150° (PRE-06).">
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
<label><input type="checkbox" name="aspect_minor" value="true" {{ 'checked' if form.aspect_minor else '' }}> aspekty poboczne (30/45/150°)</label>
</div>
{% endif %}
@@ -10,7 +10,7 @@
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
{% if result.house_warnings %}
{% for w in result.house_warnings %}
<p class="house-warning">{{ w }}</p>
<p class="notice-warn">{{ w }}</p>
{% endfor %}
{% endif %}
@@ -29,6 +29,9 @@
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #}
{% if result.house_systems and result.house_systems | length > 1 %}
<div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div>
{# Przy komplecie systemów tabela jest szersza niż okno — przewija się SAMA,
zamiast rozpychać stronę (numer domu zostaje przyklejony do lewej). #}
<div class="table-scroll">
<table class="angles">
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ house_label(hs.system) }}</th>{% endfor %}</tr></thead>
<tbody>
@@ -41,6 +44,7 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<table>
@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% set nav_active = "accounts" %}
{% block title %}Konta{% endblock %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-accounts.css') }}">
{% endblock %}
{% block content %}
<p class="muted">
Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong>
pochodzi z konfiguracji środowiska, ma wszystkie uprawnienia i jest jedynym,
które może zarządzać pozostałymi — dlatego nie ma go na tej liście i nie da się
go stąd skasować.
</p>
<p class="muted small">
Osoba na koncie ograniczonym widzi wyłącznie przyznane jej zakładki i opcje.
Nic w programie nie pokazuje jej, że istnieje coś więcej — niedostępne adresy
odpowiadają tak samo jak nieistniejące.
</p>
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Konta ({{ users | length }})</div>
{% if not users %}
<p class="muted">Nie ma jeszcze żadnego konta poza administracyjnym.</p>
{% endif %}
{% for login, row in users | dictsort %}
<form method="post" action="/accounts/update" class="card">
<input type="hidden" name="login" value="{{ login }}">
<div class="account-head">
<strong>{{ login }}</strong>
<span class="muted small">{{ row.created[:10] }}</span>
</div>
<label class="account-note">Opis
<input type="text" name="note" value="{{ row.note }}" placeholder="np. do czego to konto służy">
</label>
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
<div class="opts systems" title="{{ 'Zakładki widoczne w nawigacji.' if group == 'Ekrany' else 'Poziomy złożoności wewnątrz ekranów.' }}">
<span class="muted small opts-label">{{ group }}</span>
{% for f in items %}
<label title="{{ f.hint }}">
<input type="checkbox" name="granted" value="{{ f.key }}"
{{ 'checked' if f.key in row.features else '' }}> {{ f.label }}
</label>
{% endfor %}
</div>
{% endfor %}
<div class="opts">
<label>Nowe hasło (puste = bez zmiany)
<input type="password" name="password" autocomplete="new-password" placeholder="••••••">
</label>
</div>
<div class="actions">
<button type="submit">Zapisz</button>
<button type="submit" formaction="/accounts/sesje" class="ghost"
title="Zamyka otwarte sesje tego konta na wszystkich urządzeniach. Hasło zostaje bez zmian.">
Zamknij sesje
</button>
<button type="submit" formaction="/accounts/delete" class="danger"
onclick="return confirm('Skasować konto {{ login }}? Tej operacji nie da się cofnąć.')">
Skasuj konto
</button>
</div>
</form>
{% endfor %}
<div class="meta">Nowe konto</div>
<form method="post" action="/accounts/create" class="card">
<div class="grid">
<label>Login <input type="text" name="login" required autocomplete="off"></label>
<label>Hasło <input type="password" name="password" required autocomplete="new-password"></label>
<label>Opis <input type="text" name="note" placeholder="np. do czego to konto służy"></label>
</div>
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
<div class="opts systems">
<span class="muted small opts-label">{{ group }}</span>
{% for f in items %}
<label title="{{ f.hint }}"><input type="checkbox" name="granted" value="{{ f.key }}"> {{ f.label }}</label>
{% endfor %}
</div>
{% endfor %}
<div class="actions"><button type="submit">Załóż konto</button></div>
</form>
<details class="loc">
<summary>Gdzie to jest zapisane</summary>
<p class="muted small">
Konta trzymamy w pliku <code>{{ store_path }}</code> — hasła wyłącznie jako
hash scrypt, nigdy jawnie. Zapis jest atomowy, więc przerwanie w trakcie nie
obetnie pliku i nie skasuje wszystkich kont naraz.
</p>
</details>
{% endblock %}
+45 -16
View File
@@ -4,34 +4,63 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="{{ static('styles.css') }}">
{# Wspólne dane formularza między zakładkami (PRE-21). W <head> z `defer`
CELOWO: skrypty defer wykonują się w kolejności dokumentu, więc ten zdąży
odtworzyć współrzędne, ZANIM geo.js zbuduje mapę — mapa startuje od razu
we właściwym miejscu, bez przestawiania. #}
<script defer src="{{ static('formsync.js') }}"></script>
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
<script defer src="{{ static('wheelzoom.js') }}"></script>
{# 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
programu: nazwa selektora opowiada o funkcji tak samo dobrze jak przycisk. #}
<link rel="stylesheet" href="{{ static('base.css') }}">
{% block styles %}{% endblock %}
{# Skrypty dokłada ekran, który ich potrzebuje. Ładowane stąd docierały na
KAŻDĄ stronę — także do konta, które ma wyłącznie Pliki — a ich treść
wymienia z nazwy ekrany, których takie konto nie ma prawa znać.
Blok jest w <head>, bo `defer` wykonuje skrypty w kolejności dokumentu:
formsync musi odtworzyć współrzędne, ZANIM geo.js zbuduje mapę. #}
{% block head_scripts %}{% endblock %}
</head>
<body>
<main>
<header class="topbar">
<h1>astrololo</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). #}
<nav>
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
<a href="/synastry" class="{% block nav_synastry %}{% endblock %}">Synastria</a>
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
<a href="/settings" class="{% block nav_settings %}{% endblock %}">Ustawienia</a>
{% 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 %}
{# Pozycje administracyjne z REJESTRU, nie z listy w szablonie: wpisany tu
na sztywno odsyłacz zostawał w produkcie, który tego ekranu nie ma —
razem z nazwą ekranu i martwym adresem. #}
{% if can(request, 'admin') %}
{% for klucz, adres, etykieta in nav_admin() %}
<a href="{{ adres }}" class="{{ 'active' if nav_active is defined and nav_active == klucz else '' }}">{{ etykieta }}</a>
{% endfor %}
{% endif %}
{# 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
w połowie bezużyteczny: na wspólnym komputerze nie wiadomo, kogo się
wylogowuje. Bez włączonego logowania nie ma z czego wychodzić. #}
{% if auth_on() %}
<span class="whoami" title="Zalogowano jako">{{ current_user(request) }}</span>
{# POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej
stronie, żeby wylogować kogoś bez jego wiedzy. #}
<form method="post" action="/wyloguj" class="wyloguj-form">
<button type="submit" class="wyloguj">Wyloguj</button>
</form>
{% endif %}
</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') %}
<footer class="foot">
prezentacja → logika → dane · widok testowy
</footer>
{% endif %}
</main>
</body>
</html>
+14 -3
View File
@@ -1,6 +1,14 @@
{% extends "base.html" %}
{% block title %}Horoskop{% endblock %}
{% block nav_chart %}active{% endblock %}
{% set nav_active = "chart" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-chart.css') }}">
{% endblock %}
{% block head_scripts %}
<script defer src="{{ static('formsync.js') }}"></script>
<script defer src="{{ static('wheelzoom.js') }}"></script>
<script defer src="{{ static('wheeltip.js') }}"></script>
{% endblock %}
{% block content %}
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
@@ -31,6 +39,7 @@
<label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
{% if can(request, 'houses_compare') %}
<label>System domów
<select name="house_system">
{% set hs = form.house_system or 'whole_sign' %}
@@ -38,8 +47,9 @@
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
{% endfor %}
</select>
<span class="muted small" title="Placidus i Koch dzielą łuk dobowy, który powyżej koła podbiegunowego (ok. 66,5°) nie istnieje — dla takich miejsc policzymy Porphyry i wyraźnie o tym napiszemy.">* nie działa za kołem podbiegunowym</span>
</label>
{% endif %}
{% if can(request, 'advanced_calc') %}
<label>Zodiak
<select name="zodiac">
{% set zd = form.zodiac or 'tropical' %}
@@ -50,6 +60,7 @@
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select>
</label>
{% endif %}
</div>
{% include "_location_picker.html" %}
{% include "_form_options.html" %}
@@ -68,7 +79,7 @@
{% if result %}
{# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #}
{% for w in result.house_warnings or [] %}
<p class="house-warning">{{ w }}</p>
<p class="notice-warn">{{ w }}</p>
{% endfor %}
<div class="meta">
Silnik: <strong>{{ result.engine }}</strong> ·
@@ -1,13 +1,28 @@
{% extends "base.html" %}
{% block title %}Skompiluj{% endblock %}
{% block nav_compile %}active{% endblock %}
{% set nav_active = "compile" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-compile.css') }}">
{% include "_dodatki_compile_style.html" ignore missing %}
{% endblock %}
{% block head_scripts %}
<script defer src="{{ static('formsync.js') }}"></script>
<script defer src="{{ static('wheelzoom.js') }}"></script>
<script defer src="{{ static('wheeltip.js') }}"></script>
{% endblock %}
{% block content %}
<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>
{# 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. #}
{# 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">
@@ -36,6 +51,7 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
<label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
{% if can(request, 'houses_compare') %}
<label>System domów
<select name="house_system">
{% set hs = form.house_system or 'whole_sign' %}
@@ -43,8 +59,9 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
{% endfor %}
</select>
<span class="muted small" title="Placidus i Koch dzielą łuk dobowy, który powyżej koła podbiegunowego (ok. 66,5°) nie istnieje — dla takich miejsc policzymy Porphyry i wyraźnie o tym napiszemy.">* nie działa za kołem podbiegunowym</span>
</label>
{% endif %}
{% if can(request, 'advanced_calc') %}
<label>Zodiak
<select name="zodiac">
{% set zd = form.zodiac or 'tropical' %}
@@ -55,15 +72,16 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select>
</label>
{% endif %}
</div>
{# Te same opcje co na „Horoskop" (wspólny plik) — synchronizowane między
zakładkami (formsync), żeby podsumowanie liczyło się z tym, co wybrano. #}
{% include "_form_options.html" %}
<div class="actions">
<button type="submit">Złóż podsumowanie</button>
<button type="button" id="pdfBtn" class="ghost"
{% if can(request, 'export') %}<button type="button" id="pdfBtn" class="ghost"
title="Generuje plik pośredni LaTeXa i kompiluje go do PDF (usługa render)">Pobierz PDF</button>
<span id="pdfNote" class="muted small"></span>
<span id="pdfNote" class="muted small"></span>{% endif %}
</div>
</form>
@@ -117,14 +135,9 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
{% include "_result_tables.html" %}
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
<div id="reportNatal"></div>
<div id="reportPredictions"></div>
{% include "_dodatki_compile_wyniki.html" ignore missing %}
{% endif %}
{# 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>
{% include "_dodatki_compile_skrypty.html" ignore missing %}
<script src="{{ static('compile.js') }}"></script>
{% endblock %}
@@ -0,0 +1,133 @@
{% extends "base.html" %}
{% set nav_active = "files" %}
{% block title %}Pliki{% endblock %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-files.css') }}">
{% endblock %}
{% block content %}
{# Opis czysto operacyjny: co robi zaznaczenie, i nic o tym, do CZEGO te dane
służą. Poprzednia treść nazywała je „bazami interpretacyjnymi" i mówiła
o „wyszukiwaniu" — czyli zdradzała i dziedzinę programu, i istnienie ekranu
wyszukiwarki. To jest jedyny ekran, który ogląda konto wgrywające dane, więc
każde zdanie na nim waży najwięcej. #}
<p class="muted">
Zaznaczony plik jest w użyciu. Odznaczony zostaje na dysku, ale nie jest używany.
</p>
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Pliki ({{ files | length }})</div>
{% if not files %}
<p class="muted">Nie ma jeszcze żadnego pliku.</p>
{% endif %}
<div class="table-scroll">
<table>
<thead>
<tr>
<th>W użyciu</th><th>Plik</th><th>Stan</th><th>Rozmiar</th><th>Zmieniony</th>
{% if can(request, 'files_input') or is_admin %}<th>Operacje</th>{% endif %}
</tr>
</thead>
<tbody>
{% for f in files %}
<tr class="{{ 'row-archived' if f.status == 'archived' else '' }}{{ ' row-held' if f.status == 'quarantine' else '' }}">
<td>
{# Archiwum jest ZAMROŻONE — nie da się go włączyć bez przywrócenia,
a przywrócić może wyłącznie administrator. #}
{% if f.status in ('active', 'ready') %}
<form method="post" action="/files/use" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<input type="hidden" name="use" value="{{ '0' if f.in_use else '1' }}">
<button type="submit" class="toggle {{ 'on' if f.in_use else 'off' }}"
title="{{ 'Odstaw ten plik' if f.in_use else 'Włącz ten plik do użytku' }}">
{{ '●' if f.in_use else '○' }}
</button>
</form>
{% else %}<span class="muted"></span>{% endif %}
</td>
<td class="mono">{{ f.name }}</td>
<td>
{{ STATUS_LABELS.get(f.status, f.status) }}
{% if f.status == 'archived' and f.archived_at %}
<span class="muted small">({{ f.archived_at[:16] | replace('T', ' ') }})</span>
{% endif %}
{% if is_admin and f.rejected_for %}
<div class="muted small">nie przeszedł: {{ f.rejected_for | join('; ') }}</div>
{% endif %}
</td>
<td class="mono">{{ '%.2f'|format(f.size_mb) if f.size_mb is not none else '—' }} MB</td>
<td class="mono">{{ f.modified or '—' }}</td>
{% if can(request, 'files_input') or is_admin %}
<td class="ops">
{% if can(request, 'files_input') and f.status in ('active', 'ready') %}
<form method="post" action="/files/archive" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<button type="submit" class="ghost"
title="Plik zostaje na dysku, zamrożony ze znacznikiem czasu — znika tylko z użytku.">Archiwizuj</button>
</form>
{% endif %}
{% if is_admin %}
{% if f.status in ('archived', 'quarantine') %}
<form method="post" action="/files/restore" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<button type="submit" class="ghost">Przywróć</button>
</form>
{% endif %}
<form method="post" action="/files/delete" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<button type="submit" class="danger"
onclick="return confirm('Skasować {{ f.name }} bezpowrotnie? Pliku nie da się odzyskać.')">Skasuj</button>
</form>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if can(request, 'files_input') %}
<div class="meta">Wgraj plik</div>
<form method="post" action="/files/upload" 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>
{% endif %}
{% if is_admin %}
{# Reguły walidacji widzi WYŁĄCZNIE administrator — cała ta sekcja nie trafia
nawet do źródła strony dla pozostałych kont. #}
<div class="meta">Reguły przyjmowania baz</div>
<p class="muted small">
Plik musi je spełnić, żeby dało się go włączyć do użytku. Plik, który ich nie
spełnia, <strong>nie jest kasowany</strong> — czeka na Twoją decyzję.
</p>
<form method="post" action="/files/rules" class="card">
<div class="grid">
<label>Dozwolone rozszerzenia (po przecinku)
<input type="text" name="extensions" value="{{ (rules.extensions or []) | join(', ') }}"></label>
<label>Maksymalny rozmiar (MB)
<input type="number" name="max_size_mb" step="1" min="0" value="{{ rules.max_size_mb }}"></label>
<label>Minimalna liczba wierszy
<input type="number" name="min_rows" step="1" min="0" value="{{ rules.min_rows }}"></label>
</div>
<div class="grid">
<label>Wymagane kolumny (po przecinku, puste = bez wymagań)
<input type="text" name="required_columns"
value="{{ (rules.required_columns or []) | join(', ') }}"></label>
</div>
<div class="opts">
<label><input type="checkbox" name="reject_duplicate_content" value="1"
{{ 'checked' if rules.reject_duplicate_content else '' }}>
odrzucaj pliki o treści identycznej z już wgraną</label>
</div>
<div class="actions"><button type="submit">Zapisz reguły</button></div>
</form>
{% endif %}
{% endblock %}
@@ -1,6 +1,13 @@
{% extends "base.html" %}
{% block title %}Interpretacje{% endblock %}
{% block nav_interp %}active{% endblock %}
{% set nav_active = "interpret" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-interpret.css') }}">
{% include "_dodatki_interpret_style.html" ignore missing %}
{% endblock %}
{% block head_scripts %}
<script defer src="{{ static('formsync.js') }}"></script>
{% endblock %}
{% block content %}
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
@@ -35,14 +42,15 @@
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
</div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
{% include "_prompt_block.html" %}
<p class="muted small" id="natalNote"></p>
{# 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. #}
{% 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>
<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>
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</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 %}
{% include "_dodatki_interpret_akcje.html" ignore missing %}
<span id="geoNote" class="muted small"></span>
</div>
</form>
@@ -99,8 +107,7 @@
<script src="{{ static('now.js') }}"></script>
<script src="{{ static('copy.js') }}"></script>
<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>
{# 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 %}
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · 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>
{% if request.query_params.get('wylogowano') %}
<p class="done-note">Wylogowano.</p>
{% endif %}
{% if blad %}<p class="notice-warn">{{ blad }}</p>{% endif %}
<form method="post" action="/logowanie" class="card login-card">
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
po stronie serwera — pole formularza jest wejściem od użytkownika. #}
<input type="hidden" name="dokad" value="{{ dokad }}">
<label>Login
<input type="text" name="login" value="{{ login or '' }}" required
autocomplete="username" autofocus></label>
<label>Hasło
<input type="password" name="haslo" required autocomplete="current-password"></label>
<div class="actions"><button type="submit">Zaloguj</button></div>
</form>
<p class="muted small login-note">
{# Bez wskazywania, że dostęp ktoś „zakłada": to mówi wprost, że istnieje
konto o wyższych uprawnieniach i że własne jest okrojone. #}
Nie pamiętasz hasła? Skontaktuj się z osobą, od której masz dane logowania.
</p>
</main>
</body>
</html>
@@ -1,6 +1,9 @@
{% extends "base.html" %}
{% block title %}Ustawienia{% endblock %}
{% block nav_settings %}active{% endblock %}
{% set nav_active = "settings" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-settings.css') }}">
{% endblock %}
{% block content %}
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
@@ -1,6 +1,9 @@
{% extends "base.html" %}
{% block title %}Sygnifikatory{% endblock %}
{% block nav_sig %}active{% endblock %}
{% set nav_active = "significators" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-significators.css') }}">
{% endblock %}
{% block content %}
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
@@ -1,6 +1,9 @@
{% extends "base.html" %}
{% block title %}Synastria{% endblock %}
{% block nav_synastry %}active{% endblock %}
{% set nav_active = "synastry" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-synastry.css') }}">
{% endblock %}
{% block content %}
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
@@ -1,6 +1,13 @@
{% extends "base.html" %}
{% block title %}Kalendarz{% endblock %}
{% block nav_timeline %}active{% endblock %}
{% set nav_active = "timeline" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-timeline.css') }}">
{% include "_dodatki_timeline_style.html" ignore missing %}
{% endblock %}
{% block head_scripts %}
<script defer src="{{ static('formsync.js') }}"></script>
{% endblock %}
{% block content %}
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
@@ -40,12 +47,11 @@
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
</label>
</div>
{% include "_prompt_block.html" %}
{% 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>
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
{% include "_dodatki_timeline_akcje.html" ignore missing %}
<span id="geoNote" class="muted small"></span>
</div>
</form>
@@ -85,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>
@@ -93,8 +99,5 @@
<script src="{{ static('now.js') }}"></script>
<script src="{{ static('copy.js') }}"></script>
<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>
{% include "_dodatki_timeline_skrypty.html" ignore missing %}
{% endblock %}

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