Compare commits

...

5 Commits

Author SHA1 Message Date
gitea 5a7f7e1b13 fix(konta): awaria magazynu tłumaczy się zamiast dawać gołe 500
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m29s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Failing after 4m55s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 17s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m37s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 4m53s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 19s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
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-07 19:37:21 +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
36 changed files with 1886 additions and 82 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) assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}") 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") print("SMOKE OK")
PY PY
Binary file not shown.
+82
View File
@@ -0,0 +1,82 @@
# 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.
## 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.
+62 -1
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from fastapi import FastAPI from fastapi import FastAPI, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
import swisseph as swe import swisseph as swe
@@ -69,6 +69,67 @@ def positions(req: PositionsRequest) -> dict:
return {"engine": "swisseph", "positions": rows} 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") @app.get("/health")
def health() -> dict: def health() -> dict:
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"} return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
+8
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), # współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak, # a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
# z surowych długości/szerokości silnika. # z surowych długości/szerokości silnika.
# ε 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)) eps = mean_obliquity(Z.julian_day(moment.when_utc))
result["obliquity_kind"] = "mean"
result["obliquity"] = round(eps, 6) result["obliquity"] = round(eps, 6)
for pdict, obj in zip(result["positions"], positions): for pdict, obj in zip(result["positions"], positions):
dec = declination(obj.longitude, obj.latitude, eps) dec = declination(obj.longitude, obj.latitude, eps)
+43 -3
View File
@@ -321,6 +321,22 @@ def _cusps_topocentric(ramc: float, eps: float, lat: float,
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik 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 bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
konstrukcji „ascendent pod biegunem".""" 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)) er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
epole = _ecliptic_pole(er) epole = _ecliptic_pole(er)
# Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym), # Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
@@ -534,14 +550,38 @@ def cusps_detailed(ramc: float, eps: float, lat: float, system: str) -> CuspSet:
FALLBACK_SYSTEM, system, str(e)) 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: 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) lon = norm360(lon)
forward = _runs_forward(cusp_list)
for i in range(12): for i in range(12):
start = cusp_list[i] start = cusp_list[i]
end = cusp_list[(i + 1) % 12] end = cusp_list[(i + 1) % 12]
span = (end - start) % 360.0 if forward:
offset = (lon - start) % 360.0 span, offset = (end - start) % 360.0, (lon - start) % 360.0
else:
span, offset = (start - end) % 360.0, (start - lon) % 360.0
if offset < span: if offset < span:
return i + 1 return i + 1
return 12 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. 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) t = self.ts.from_datetime(moment.when_utc)
ramc = norm360(t.gast * 15.0 + moment.lon) ramc = norm360(t.gast * 15.0 + moment.lon)
eps = mean_obliquity(t.tt) return ramc, true_obliquity(t)
return ramc, eps
def health(self) -> dict: def health(self) -> dict:
return {"engine": self.name, "status": "ok", "kernel": self.kernel} 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
+103 -6
View File
@@ -213,7 +213,7 @@ def test_topocentric_cusps_stay_in_order_between_mc_and_ascendant():
def d(a, b): def d(a, b):
return H.norm360(b - a) return H.norm360(b - a)
for lat in (-89.9, -79.55, -67.18, 0.0, 50.06, 73.69, 80.66, 89.9): 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") c = H.cusps_for(129.2387, 23.4393, lat, "topocentric")
mc, c11, c12, asc = c[9], c[10], c[11], c[0] mc, c11, c12, asc = c[9], c[10], c[11], c[0]
forward = d(mc, c11) + d(c11, c12) + d(c12, asc) forward = d(mc, c11) + d(c11, c12) + d(c12, asc)
@@ -223,13 +223,23 @@ def test_topocentric_cusps_stay_in_order_between_mc_and_ascendant():
f"φ={lat}: MC={mc:.3f} 11={c11:.3f} 12={c12:.3f} Asc={asc:.3f} nie po kolei" f"φ={lat}: MC={mc:.3f} 11={c11:.3f} 12={c12:.3f} Asc={asc:.3f} nie po kolei"
def test_topocentric_needs_no_domain_limit(): def test_topocentric_refuses_where_its_houses_stop_partitioning_the_circle():
"""W przeciwieństwie do Placidusa/Kocha topocentric liczy się WSZĘDZIE — """Topocentric ma granicę INNEGO rodzaju niż Placidus i Koch.
nie ma tu granicy dziedziny do wyprowadzenia, więc nie wolno jej udawać."""
for lat in (0.0, 66.5, 69.65, 89.9, -89.9): 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 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") cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
assert not cs.is_fallback and cs.notice is None assert cs.is_fallback and cs.notice and "topocentric" in cs.notice
def test_whole_sign_aries_ignores_the_ascendant(): def test_whole_sign_aries_ignores_the_ascendant():
@@ -249,3 +259,90 @@ def test_equal_mc_starts_the_tenth_house_exactly_on_the_midheaven():
assert c[0] == pytest.approx(H.norm360(mc + 90.0), abs=1e-9) assert c[0] == pytest.approx(H.norm360(mc + 90.0), abs=1e-9)
for i in range(12): # nadal równe 30° 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) 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
+187
View File
@@ -0,0 +1,187 @@
"""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"))),
"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 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)
+123
View File
@@ -0,0 +1,123 @@
"""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
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
# ── ekrany (zakładki) ────────────────────────────────────────────────────
SCREENS: tuple[Feature, ...] = (
Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.", "Ekrany", "/"),
Feature("interpret", "Interpretacje", "Interpretacja natalna z baz i od modelu.", "Ekrany", "/interpret"),
Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.", "Ekrany", "/timeline"),
Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.", "Ekrany", "/synastry"),
Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach interpretacyjnych.", "Ekrany", "/significators"),
Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.", "Ekrany", "/compile"),
Feature("settings", "Ustawienia", "Podgląd baz i konfiguracji modelu.", "Ekrany", "/settings"),
)
# ── rozszerzenia (poziomy złożoności wewnątrz ekranów) ───────────────────
EXTRAS: tuple[Feature, ...] = (
Feature("houses_compare", "Porównanie systemów domów",
"Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
"Rozszerzenia"),
Feature("extra_charts", "Wykresy dodatkowe",
"Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia"),
Feature("advanced_calc", "Obliczenia zaawansowane",
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
"Rozszerzenia"),
Feature("ai", "Generowanie tekstu przez model",
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
"Rozszerzenia"),
Feature("export", "Eksport plików",
"Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
)
ALL: tuple[Feature, ...] = SCREENS + EXTRAS
BY_KEY: dict[str, Feature] = {f.key: f for f in ALL}
GRANTABLE: frozenset[str] = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
# ── mapa trasa → wymagane uprawnienie ────────────────────────────────────
# None = dostępne każdemu zalogowanemu. Dotyczy to pomocników (geokoder, strefa
# czasowa, health), które same w sobie NICZEGO nie zdradzają o funkcjach programu.
ROUTES: dict[tuple[str, str], str | None] = {
("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
("POST", "/"): "chart",
("GET", "/interpret"): "interpret",
("POST", "/interpret"): "interpret",
("GET", "/timeline"): "timeline",
("POST", "/timeline"): "timeline",
("GET", "/synastry"): "synastry",
("POST", "/synastry"): "synastry",
("GET", "/significators"): "significators",
("POST", "/significators"): "significators",
("GET", "/compile"): "compile",
("POST", "/compile"): "compile",
("POST", "/compile/pdf"): "export",
("GET", "/settings"): "settings",
("POST", "/horoscope/stream"): "ai",
("GET", "/accounts"): ADMIN,
("POST", "/accounts/create"): ADMIN,
("POST", "/accounts/update"): ADMIN,
("POST", "/accounts/delete"): ADMIN,
("GET", "/geocode"): None,
("GET", "/reverse"): None,
("GET", "/timezone"): None,
("GET", "/health"): None,
}
def required(method: str, path: 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ę."""
return ROUTES.get((method.upper(), path), ADMIN)
def can(perms: frozenset[str], feature: str | None) -> bool:
return feature is None or feature in perms
def screens_for(perms: frozenset[str]) -> list[Feature]:
"""Zakładki do pokazania w nawigacji — w stałej kolejności katalogu."""
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)
+147 -15
View File
@@ -18,25 +18,32 @@ from pathlib import Path
import httpx import httpx
from fastapi import FastAPI, Form, HTTPException, Query, Request from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app import accounts as accounts_store
from app import chartwheel as chartwheel_mod from app import chartwheel as chartwheel_mod
from app import features
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
from app import geocode, security from app import geocode, security
from app.clients.logic_client import LogicClient from app.clients.logic_client import LogicClient
from app.config import DEFAULT_LOCATION_LABEL, default_form from app.config import DEFAULT_LOCATION_LABEL, default_form
app = FastAPI(title="astrololo · warstwa prezentacji") # 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)
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
logic = LogicClient() logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32) security.install(app) # logowanie + limit żądań (LOG-32)
# ── cache-busting statyki (PRE-26) ───────────────────────────────────────── # ── cache-busting statyki (PRE-27) ─────────────────────────────────────────
# Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache). # 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 → # 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). # przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej).
@@ -64,6 +71,45 @@ templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
def _perms(request: Request) -> frozenset[str]:
"""Uprawnienia zalogowanego konta — pusty zbiór, gdy nie wiadomo kto to."""
who = getattr(request.state, "principal", None)
return who.permissions if who is not None else frozenset()
# Szablony pytają o uprawnienia PRZEZ TE DWIE FUNKCJE, nigdy o konto wprost.
# Dzięki temu nie da się w szablonie napisać warunku na login — a to jest
# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym
# nikt już nie pamięta.
templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request))
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]: def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC. """Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
@@ -108,6 +154,16 @@ def _logic_error(e: Exception) -> str:
# ---------------- Horoskop: pozycje (strona główna) ---------------- # ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def chart_form(request: Request): 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)
return HTMLResponse("<h1>Brak przypisanych funkcji</h1>"
"<p>Skontaktuj się z osobą, która zakładała konto.</p>",
status_code=200)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "chart.html", request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
@@ -140,22 +196,27 @@ def chart_compute(
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor, "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables, "stations": stations, "zodiac": zodiac, "tables": tables,
"wheel_orientation": wheel_orientation} "wheel_orientation": wheel_orientation}
form = _limit_options(request, form)
ctx: dict = {"form": form, "result": None, "error": None, "moment": None} ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try: try:
iso_utc, label = _build_utc(date, time, tz_offset) iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label ctx["moment"] = label
ctx["result"] = logic.positions( ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon, when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, house_systems=house_systems, house_system=form["house_system"], house_systems=form["house_systems"],
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus, aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
aspect_minor=aspect_minor, aspect_minor=form["aspect_minor"],
stations=stations, zodiac=zodiac, tables=tables, stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
) )
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation) 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["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6) ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6) ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
except (httpx.HTTPError,) as e: except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e) ctx["error"] = _logic_error(e)
except ValueError as e: except ValueError as e:
@@ -206,22 +267,25 @@ def compile_build(
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor, "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables, "stations": stations, "zodiac": zodiac, "tables": tables,
"wheel_orientation": wheel_orientation} "wheel_orientation": wheel_orientation}
form = _limit_options(request, form)
ctx: dict = {"form": form, "result": None, "error": None, "moment": None} ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try: try:
iso_utc, label = _build_utc(date, time, tz_offset) iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label ctx["moment"] = label
ctx["result"] = logic.positions( ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon, when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, house_systems=house_systems, house_system=form["house_system"], house_systems=form["house_systems"],
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus, aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
aspect_minor=aspect_minor, aspect_minor=form["aspect_minor"],
stations=stations, zodiac=zodiac, tables=tables, stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
) )
from app import chartwheel from app import chartwheel
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation) 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["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6) ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6) ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
except (httpx.HTTPError,) as e: except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e) ctx["error"] = _logic_error(e)
except ValueError as e: except ValueError as e:
@@ -608,6 +672,74 @@ def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00")
return res return res
# ---------------- 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,
"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/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}”.")
@app.get("/health") @app.get("/health")
def health() -> dict: def health() -> dict:
return {"status": "ok", "layer": "presentation"} return {"status": "ok", "layer": "presentation"}
+67 -10
View File
@@ -42,6 +42,7 @@ import os
import secrets import secrets
import time import time
from collections import deque from collections import deque
from dataclasses import dataclass
from fastapi import Request from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
@@ -131,12 +132,36 @@ def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES) return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
def authenticate(header: str | None) -> str | None: @dataclass(frozen=True)
"""Nagłówek Basic → LOGIN zalogowanej osoby albo None. class Principal:
"""Kto się zalogował i co wolno mu zobaczyć."""
Zwracamy login (nie bool), bo dziennik audytowy musi wiedzieć KTO — to sedno login: str
PRE-17. Nieznany login i złe hasło traktujemy tak samo (bez podpowiadania, permissions: frozenset[str]
które konto istnieje).""" is_admin: bool
def _admin_permissions() -> frozenset[str]:
"""Administrator widzi wszystko — z definicji, bez wpisywania listy ręcznie.
Liczone z katalogu, więc dopisanie nowej 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 principal(header: str | None) -> Principal | None:
"""Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują.
Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne
uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej
założenie w pliku konta o loginie administratora przesłoniłoby konto
administracyjne i odebrało mu uprawnienia.
Nieznany login i złe hasło traktujemy tak samo: odmowa nie mówi, które konto
istnieje."""
if not header or not header.lower().startswith("basic "): if not header or not header.lower().startswith("basic "):
return None return None
try: try:
@@ -144,10 +169,26 @@ def authenticate(header: str | None) -> str | None:
user, _, password = raw.partition(":") user, _, password = raw.partition(":")
except (binascii.Error, UnicodeDecodeError, IndexError): except (binascii.Error, UnicodeDecodeError, IndexError):
return None return None
spec = accounts().get(user) spec = accounts().get(user)
if spec is None: if spec is not None:
return (Principal(user, _admin_permissions(), True)
if _secret_matches(spec, password) else None)
from app import accounts as store
managed = store.secret_of(user)
if managed is None:
return None return None
return user if _secret_matches(spec, password) else None if not _secret_matches(managed, password):
return None
return Principal(user, store.permissions_of(user), False)
def authenticate(header: str | None) -> str | None:
"""Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz."""
who = principal(header)
return who.login if who else None
def client_ip(request: Request) -> str: def client_ip(request: Request) -> str:
@@ -264,16 +305,32 @@ def install(app) -> None:
status_code=429, headers={"Retry-After": "60"}, status_code=429, headers={"Retry-After": "60"},
) )
user = "" from app import features
user, who = "", None
if auth_enabled(): if auth_enabled():
user = authenticate(request.headers.get("authorization")) or "" who = principal(request.headers.get("authorization"))
if not user: if who is None:
_audit(request, "-", 401, (time.monotonic() - started) * 1000) _audit(request, "-", 401, (time.monotonic() - started) * 1000)
return HTMLResponse( return HTMLResponse(
"<h1>401 — wymagane logowanie</h1>", status_code=401, "<h1>401 — wymagane logowanie</h1>", status_code=401,
headers={"WWW-Authenticate": 'Basic realm="astrololo"'}, headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
) )
user = who.login
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.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) response = await call_next(request)
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000) _audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
return response return response
@@ -81,6 +81,20 @@ svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out; 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 */ 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; }
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; } form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
.row { display: flex; gap: .5rem; } .row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; } .row input[type=text] { flex: 1; }
@@ -91,6 +105,16 @@ input, select, button {
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; } button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
button:hover { filter: brightness(1.1); } button:hover { filter: brightness(1.1); }
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; } .opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
/* 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; } .opts input[type=number] { width: 5rem; }
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; } .meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
@@ -108,6 +132,21 @@ button:hover { filter: brightness(1.1); }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; } .error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.empty { color: var(--muted); } .empty { color: var(--muted); }
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; } table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
/* 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);
}
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); } 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; } th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
@@ -189,3 +228,22 @@ textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
bez przepisywania ich na HTML. */ bez przepisywania ich na HTML. */
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line); .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; } border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */
.account-card {
margin-top: 1rem; padding: 1rem 1.25rem;
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
}
.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; }
.account-note input { width: 100%; }
.account-card .actions { margin-top: .9rem; }
button.danger { background: #8b2f2f; }
/* Potwierdzenie operacji. Odróżnia się od .house-warning, 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);
}
@@ -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);
});
})();
@@ -1,19 +1,26 @@
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i „Skompiluj" (/compile). Jeden plik, {# 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 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. #} podsumowania). Wszystkie synchronizowane między zakładkami przez formsync.js. #}
{% if can(request, 'advanced_calc') %}
<div class="opts"> <div class="opts">
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}> <label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
licz stacje planet (wolniejsze)</label> licz stacje planet (wolniejsze)</label>
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}> <label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label> tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
</div> </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 [] %} {% 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 %} {% 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> <label><input type="checkbox" name="house_systems" value="{{ value }}" {{ 'checked' if value in chosen else '' }}> {{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</label>
{% endfor %} {% endfor %}
</div> </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."> <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' %} {% set wo = form.wheel_orientation or 'asc' %}
<span class="muted small">Kosmogram — po lewej stronie:</span> <span class="muted small">Kosmogram — po lewej stronie:</span>
@@ -21,8 +28,11 @@
<label><input type="radio" name="wheel_orientation" value="{{ value }}" {{ 'checked' if wo == value else '' }}> {{ name }}</label> <label><input type="radio" name="wheel_orientation" value="{{ value }}" {{ 'checked' if wo == value else '' }}> {{ name }}</label>
{% endfor %} {% endfor %}
</div> </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)."> <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>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>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> <label><input type="checkbox" name="aspect_minor" value="true" {{ 'checked' if form.aspect_minor else '' }}> aspekty poboczne (30/45/150°)</label>
</div> </div>
{% endif %}
@@ -29,6 +29,9 @@
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #} {# 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 %} {% 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> <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"> <table class="angles">
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ house_label(hs.system) }}</th>{% endfor %}</tr></thead> <thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ house_label(hs.system) }}</th>{% endfor %}</tr></thead>
<tbody> <tbody>
@@ -41,6 +44,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endif %} {% endif %}
<table> <table>
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% set nav_active = "accounts" %}
{% block title %}Konta{% 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="house-warning">{{ 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="account-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/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="account-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 %}
+10 -7
View File
@@ -13,19 +13,22 @@
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na {# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #} zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
<script defer src="{{ static('wheelzoom.js') }}"></script> <script defer src="{{ static('wheelzoom.js') }}"></script>
<script defer src="{{ static('wheeltip.js') }}"></script>
</head> </head>
<body> <body>
<main> <main>
<header class="topbar"> <header class="topbar">
<h1>astrololo</h1> <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> <nav>
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a> {% for tab in nav_screens(request) %}
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a> <a href="{{ tab.href }}" class="{{ 'active' if nav_active is defined and nav_active == tab.key else '' }}">{{ tab.label }}</a>
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a> {% endfor %}
<a href="/synastry" class="{% block nav_synastry %}{% endblock %}">Synastria</a> {% if can(request, 'admin') %}
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a> <a href="/accounts" class="{{ 'active' if nav_active is defined and nav_active == 'accounts' else '' }}">Konta</a>
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a> {% endif %}
<a href="/settings" class="{% block nav_settings %}{% endblock %}">Ustawienia</a>
</nav> </nav>
</header> </header>
{% block content %}{% endblock %} {% block content %}{% endblock %}
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Horoskop{% endblock %} {% block title %}Horoskop{% endblock %}
{% block nav_chart %}active{% endblock %} {% set nav_active = "chart" %}
{% block content %} {% block content %}
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p> <p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
@@ -31,6 +31,7 @@
<label>Długość (lon, + E) <label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}"> <input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label> </label>
{% if can(request, 'houses_compare') %}
<label>System domów <label>System domów
<select name="house_system"> <select name="house_system">
{% set hs = form.house_system or 'whole_sign' %} {% set hs = form.house_system or 'whole_sign' %}
@@ -38,8 +39,9 @@
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option> <option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
{% endfor %} {% endfor %}
</select> </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> </label>
{% endif %}
{% if can(request, 'advanced_calc') %}
<label>Zodiak <label>Zodiak
<select name="zodiac"> <select name="zodiac">
{% set zd = form.zodiac or 'tropical' %} {% set zd = form.zodiac or 'tropical' %}
@@ -50,6 +52,7 @@
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option> <option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select> </select>
</label> </label>
{% endif %}
</div> </div>
{% include "_location_picker.html" %} {% include "_location_picker.html" %}
{% include "_form_options.html" %} {% include "_form_options.html" %}
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Skompiluj{% endblock %} {% block title %}Skompiluj{% endblock %}
{% block nav_compile %}active{% endblock %} {% set nav_active = "compile" %}
{% block content %} {% block content %}
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie <p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
@@ -36,6 +36,7 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
<label>Długość (lon, + E) <label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}"> <input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label> </label>
{% if can(request, 'houses_compare') %}
<label>System domów <label>System domów
<select name="house_system"> <select name="house_system">
{% set hs = form.house_system or 'whole_sign' %} {% set hs = form.house_system or 'whole_sign' %}
@@ -43,8 +44,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> <option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
{% endfor %} {% endfor %}
</select> </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> </label>
{% endif %}
{% if can(request, 'advanced_calc') %}
<label>Zodiak <label>Zodiak
<select name="zodiac"> <select name="zodiac">
{% set zd = form.zodiac or 'tropical' %} {% set zd = form.zodiac or 'tropical' %}
@@ -55,15 +57,16 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option> <option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select> </select>
</label> </label>
{% endif %}
</div> </div>
{# Te same opcje co na „Horoskop" (wspólny plik) — synchronizowane między {# Te same opcje co na „Horoskop" (wspólny plik) — synchronizowane między
zakładkami (formsync), żeby podsumowanie liczyło się z tym, co wybrano. #} zakładkami (formsync), żeby podsumowanie liczyło się z tym, co wybrano. #}
{% include "_form_options.html" %} {% include "_form_options.html" %}
<div class="actions"> <div class="actions">
<button type="submit">Złóż podsumowanie</button> <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> 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> </div>
</form> </form>
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Interpretacje{% endblock %} {% block title %}Interpretacje{% endblock %}
{% block nav_interp %}active{% endblock %} {% set nav_active = "interpret" %}
{% block content %} {% block content %}
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p> <p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
@@ -40,7 +40,7 @@
<div class="actions"> <div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button> <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="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> {% 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 %}
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</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> <button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
<span id="geoNote" class="muted small"></span> <span id="geoNote" class="muted small"></span>
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Ustawienia{% endblock %} {% block title %}Ustawienia{% endblock %}
{% block nav_settings %}active{% endblock %} {% set nav_active = "settings" %}
{% block content %} {% block content %}
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p> <p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sygnifikatory{% endblock %} {% block title %}Sygnifikatory{% endblock %}
{% block nav_sig %}active{% endblock %} {% set nav_active = "significators" %}
{% block content %} {% block content %}
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p> <p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Synastria{% endblock %} {% block title %}Synastria{% endblock %}
{% block nav_synastry %}active{% endblock %} {% set nav_active = "synastry" %}
{% block content %} {% 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> <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,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Kalendarz{% endblock %} {% block title %}Kalendarz{% endblock %}
{% block nav_timeline %}active{% endblock %} {% set nav_active = "timeline" %}
{% block content %} {% block content %}
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p> <p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
+29 -6
View File
@@ -31,7 +31,16 @@ def _client():
# ───────────────────────────────── nawigacja i trasa ───────────────────── # ───────────────────────────────── nawigacja i trasa ─────────────────────
def test_tab_is_in_the_menu(): def test_tab_is_in_the_menu():
assert 'href="/compile"' in BASE and "Skompiluj" in BASE """Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
from app import features
tab = features.BY_KEY["compile"]
assert tab in features.SCREENS
assert tab.href and tab.label
def test_get_renders_empty_form(): def test_get_renders_empty_form():
@@ -203,11 +212,25 @@ def test_compile_form_carries_the_options():
assert f in opts, f"brak opcji we wspólnym pliku: {f}" assert f in opts, f"brak opcji we wspólnym pliku: {f}"
def test_compile_handler_recomputes_with_all_options(): def test_compile_handler_recomputes_with_all_options(monkeypatch):
assert "house_systems: list[str] = Form([])" in MAIN """„Skompiluj" przelicza horoskop z KOMPLETEM opcji z formularza.
assert "stations: bool = Form(False)" in MAIN
assert "house_systems=house_systems" in MAIN # przekazane do logiki To jest ta regresja z 2026-07-28: podsumowanie liczyło się z okrojonym
assert "stations=stations, zodiac=zodiac, tables=tables" in MAIN zestawem i miało braki względem tego, co widać na „Horoskopie". Sprawdzamy
przez wywołanie trasy, nie przez szukanie napisów w main.py."""
seen = {}
from app.main import app, logic
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _pdf_sample_chart())[1])
r = _client().post("/compile", data={
"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94", "house_system": "koch",
"house_systems": ["koch", "equal"], "stations": "true", "tables": "true",
"zodiac": "sidereal_lahiri", "aspect_minor": "true"})
assert r.status_code == 200, r.text[:300]
assert seen["house_systems"] == ["koch", "equal"]
assert seen["stations"] is True and seen["tables"] is True
assert seen["zodiac"] == "sidereal_lahiri" and seen["aspect_minor"] is True
def test_compile_pdf_recomputes_with_all_options(): def test_compile_pdf_recomputes_with_all_options():
@@ -34,7 +34,12 @@ def _render(name: str, **ctx) -> str:
env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True) env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True)
env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED, env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED,
house_label=house_label, static=lambda p: p) house_label=house_label, static=lambda p: p,
# Domyślnie renderujemy jak dla konta z PEŁNYMI uprawnieniami —
# inaczej testy opcji sprawdzałyby pusty formularz. Ograniczenia
# mają własne testy w test_kontrola_dostepu.py.
can=lambda request, key: ctx.pop("_denied", None) != key,
nav_screens=lambda request: [])
return env.get_template(name).render(**ctx) return env.get_template(name).render(**ctx)
@@ -57,9 +62,26 @@ def test_form_marks_systems_with_a_domain_limit():
assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}" assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}"
def test_handler_accepts_and_passes_house_systems(): def test_handler_accepts_and_passes_house_systems(monkeypatch):
assert "house_systems: list[str] = Form([])" in MAIN """Zaznaczone systemy DOCHODZĄ do warstwy logicznej.
assert "house_systems=house_systems" in MAIN # przekazane do logiki
Poprzednia wersja szukała napisu `house_systems=house_systems` w main.py
i pękała, gdy argument zaczął przechodzić przez słownik opcji — mimo że
zachowanie zostało poprawne. Sprawdzamy więc, co naprawdę wyszło na dół."""
seen = {}
def spy(**kw):
seen.update(kw)
return _minimal_chart()
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
from app.main import logic
monkeypatch.setattr(logic, "positions", spy)
r = c.post("/", data={**FORM, "house_systems": ["koch", "campanus"]})
assert r.status_code == 200, r.text[:300]
assert seen["house_systems"] == ["koch", "campanus"]
assert seen["house_system"] == "placidus"
def test_client_forwards_house_systems_to_logic(): def test_client_forwards_house_systems_to_logic():
@@ -154,3 +176,56 @@ def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
r = c.post("/", data={**FORM, "wheel_orientation": orientation}) r = c.post("/", data={**FORM, "wheel_orientation": orientation})
assert r.status_code == 200, r.text[:400] assert r.status_code == 200, r.text[:400]
assert f'value="{orientation}" checked' in r.text assert f'value="{orientation}" checked' in r.text
# ── układ strony przy komplecie systemów ────────────────────────────────
# Regresja z pomiaru na żywej stronie: przy 13 zaznaczonych systemach tabela
# porównania miała 14 kolumn i 1863 px szerokości, a że stała w rodzicu bez
# overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli
# poziomy pasek przewijania na body i rozjechany układ.
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
def test_comparison_table_scrolls_instead_of_widening_the_page():
"""Tabela ma własny kontener przewijany — przewija się ona, nie strona."""
html = _render("_result_tables.html", result={
"house_systems": [{"system": v, "cusps": [
{"sign_glyph": "", "in_sign": "0°00'00''"} for _ in range(12)]}
for v, _ in HOUSE_SYSTEMS],
"house_warnings": []})
assert '<div class="table-scroll">' in html
before = html.index('class="table-scroll"')
assert html.index("<table", before) < html.index("</div>", before), \
"tabela musi być WEWNĄTRZ kontenera przewijanego"
def test_scroll_container_actually_clips_and_pins_the_house_number():
"""Sam znacznik nie wystarczy — reguła musi istnieć w arkuszu.
Geometrii nie da się sprawdzić bez przeglądarki, więc sprawdzamy kontrakt:
kontener przewija się w poziomie, a pierwsza kolumna zostaje przyklejona
(bez tego po przewinięciu w prawo nie wiadomo, który to dom)."""
assert ".table-scroll { overflow-x: auto;" in CSS
assert ".table-scroll th:first-child, .table-scroll td:first-child {" in CSS
assert "position: sticky" in CSS
def test_system_checkboxes_use_a_grid_not_a_ragged_flex_wrap():
"""13 pozycji na flexie zawijało się w poszarpane wiersze o różnej długości."""
html = _render("_form_options.html", form={})
assert 'class="opts systems"' in html
assert ".opts.systems { display: grid;" in CSS
def test_polar_circle_note_is_explained_once_next_to_the_asterisks():
"""Przypis siedział WEWNĄTRZ <label> selektora i łamał się na dwie linie,
przez co wiersz siatki rósł z 66 do 108 px i rozjeżdżał się z sąsiednim
polem. Wyjaśnienie stoi teraz raz, przy checkboxach z gwiazdkami."""
opts = _render("_form_options.html", form={})
assert "kołem podbiegunowym" in opts
for name in ("chart.html", "compile.html"):
tpl = (APP / "templates" / name).read_text(encoding="utf-8")
select = tpl[tpl.index("<select name=\"house_system\""):]
assert "podbiegunow" not in select[:select.index("</label>")], \
f"{name}: przypis wrócił do etykiety selektora"
@@ -0,0 +1,370 @@
"""Kontrola dostępu: konta, uprawnienia i niewidzialność funkcji (PRE-27).
DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK
1. Konto widzi dokładnie to, co mu przyznano — ani mniej, ani więcej. Sprawdzane
przez PRAWDZIWE żądania, nie przez czytanie szablonów: ukrycie pola w formularzu
nie chroni przed kimś, kto zna nazwy pól.
2. Konto ograniczone nie ma SKĄD wiedzieć, że program umie więcej. To własność
negatywna — łatwo ją zepsuć przez dobre intencje („dodajmy czytelny komunikat
o braku uprawnień"), więc testy są tu wprost o tym: 404 zamiast 403, brak
pozycji w menu, brak rysunków w źródle strony.
"""
import json
import pathlib
import pytest
from app import accounts as store
from app import features, security
@pytest.fixture()
def env(tmp_path, monkeypatch):
"""Świeży plik kont + konto administracyjne ze środowiska."""
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
monkeypatch.setenv("APP_USER", "szef")
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
monkeypatch.delenv("APP_USERS", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") # limit nie jest tu przedmiotem badania
return tmp_path
def _client(monkeypatch, chart=None):
from starlette.testclient import TestClient
from app.main import app, logic
monkeypatch.setattr(logic, "positions", lambda **kw: chart or _chart())
return TestClient(app)
def _auth(user, password):
import base64
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {raw}"}
def _chart():
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0", "decimal": float(i * 30),
"sign_glyph": ""} for i in range(12)]
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0", "decimal": 0.0,
"sign_glyph": ""} for k in ("Asc", "MC", "Dsc", "IC")}
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
"sign_glyphs": [{"sign": "Aries", "glyph": ""}], "house_system": "whole_sign",
"aspects": [], "house_systems": []}
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94"}
# ── katalog i mapa tras ──────────────────────────────────────────────────
def test_every_route_declares_the_permission_it_needs():
"""Trasa bez wpisu w ROUTES to dziura, której nie widać.
Idziemy po TRASACH APLIKACJI, nie po wpisach mapy — inaczej test
potwierdzałby tylko sam siebie i nie zauważyłby nowej, nieopisanej trasy."""
from app.main import app
missing = []
for route in app.routes:
path = getattr(route, "path", None)
methods = getattr(route, "methods", None) or set()
if not path or path.startswith("/static"):
continue
for method in methods & {"GET", "POST"}:
if (method, path) not in features.ROUTES:
missing.append(f"{method} {path}")
assert not missing, f"trasy bez zadeklarowanego uprawnienia: {missing}"
def test_unknown_route_defaults_to_the_narrowest_permission():
"""Przeoczenie ma ZAMYKAĆ, nie otwierać."""
assert features.required("GET", "/cokolwiek-nowego") == features.ADMIN
def test_admin_permission_cannot_be_granted_from_a_form():
"""Nawet spreparowane żądanie nie nada uprawnień administracyjnych —
konto administracyjne pochodzi wyłącznie ze środowiska."""
assert features.ADMIN not in features.normalise([features.ADMIN, "chart"])
assert features.normalise(["chart", "wymyślone"]) == frozenset({"chart"})
# ── magazyn kont ─────────────────────────────────────────────────────────
def test_password_is_stored_only_as_a_hash(env):
store.create("ala", "hasło-ali", ["chart"])
raw = pathlib.Path(store.store_path()).read_text(encoding="utf-8")
assert "hasło-ali" not in raw
assert json.loads(raw)["users"]["ala"]["secret"].startswith("scrypt$")
def test_listing_accounts_never_exposes_secrets(env):
store.create("ala", "hasło-ali", ["chart"])
assert "secret" not in store.all_users()["ala"]
def test_create_update_delete(env):
store.create("ala", "x", ["chart"], note="do testów")
assert store.permissions_of("ala") == frozenset({"chart"})
store.update("ala", granted=["chart", "ai"])
assert store.permissions_of("ala") == frozenset({"chart", "ai"})
with pytest.raises(ValueError):
store.create("ala", "y", []) # login zajęty
store.delete("ala")
assert not store.exists("ala")
def test_empty_password_on_update_keeps_the_old_one(env):
store.create("ala", "stare", ["chart"])
before = store.secret_of("ala")
store.update("ala", granted=["chart"], password="")
assert store.secret_of("ala") == before
# ── logowanie i rozpoznanie konta ────────────────────────────────────────
def test_environment_account_is_the_administrator(env):
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
assert who and who.is_admin
assert features.ADMIN in who.permissions
assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko"
def test_managed_account_gets_exactly_its_own_permissions(env):
store.create("ala", "hasło-ali", ["chart", "ai"])
who = security.principal(_auth("ala", "hasło-ali")["Authorization"])
assert who and not who.is_admin
assert who.permissions == frozenset({"chart", "ai"})
def test_managed_account_cannot_shadow_the_administrator(env):
"""Konto założone w pliku o loginie administratora NIE MOŻE go przesłonić —
inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać."""
store.create("szef", "podszywam-się", [])
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
assert who and who.is_admin and features.ADMIN in who.permissions
def test_wrong_password_is_refused_the_same_way_as_unknown_login(env):
store.create("ala", "hasło-ali", ["chart"])
assert security.principal(_auth("ala", "złe")["Authorization"]) is None
assert security.principal(_auth("nie-ma-takiego", "cokolwiek")["Authorization"]) is None
# ── niewidzialność funkcji ───────────────────────────────────────────────
def test_forbidden_screen_answers_404_not_403(env, monkeypatch):
"""403 samo w sobie mówi „tu coś jest". Ma być nie do odróżnienia od
adresu, którego nie ma."""
store.create("ala", "x", ["chart"])
c = _client(monkeypatch)
for path in ("/significators", "/interpret", "/timeline", "/synastry",
"/compile", "/settings", "/accounts"):
r = c.get(path, headers=_auth("ala", "x"))
assert r.status_code == 404, f"{path}{r.status_code}"
assert c.get("/nie-ma-takiego-adresu", headers=_auth("ala", "x")).status_code == 404
def test_menu_shows_only_granted_screens(env, monkeypatch):
store.create("ala", "x", ["chart", "significators"])
c = _client(monkeypatch)
html = c.get("/", headers=_auth("ala", "x")).text
assert 'href="/significators"' in html
for absent in ('href="/interpret"', 'href="/timeline"', 'href="/synastry"',
'href="/compile"', 'href="/settings"', 'href="/accounts"'):
assert absent not in html, f"menu zdradza {absent}"
for word in ("Interpretacje", "Kalendarz", "Synastria", "Skompiluj", "Konta"):
assert word not in html, f"nazwa „{word}” nie powinna paść"
def test_administrator_sees_everything_including_the_accounts_tab(env, monkeypatch):
c = _client(monkeypatch)
html = c.get("/", headers=_auth("szef", "tajne-szefa")).text
for tab in features.SCREENS:
assert f'href="{tab.href}"' in html, tab.key
assert 'href="/accounts"' in html
def test_root_lands_on_the_first_granted_screen(env, monkeypatch):
"""Konto bez „Horoskopu" nie może zobaczyć 404 pod adresem głównym —
wyglądałoby to na zepsuty program, a nie na węższy zestaw funkcji."""
store.create("ala", "x", ["significators"])
c = _client(monkeypatch)
r = c.get("/", headers=_auth("ala", "x"), follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/significators"
# ── ograniczenia opcji egzekwowane NA SERWERZE ──────────────────────────
def test_crafted_request_cannot_buy_options_the_account_lacks(env, monkeypatch):
"""Ukrycie pola w formularzu chroni przed przypadkiem, nie przed kimś,
kto zna nazwy pól. Granicą jest handler."""
store.create("ala", "x", ["chart"])
seen = {}
from app.main import app, logic
from starlette.testclient import TestClient
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _chart())[1])
r = TestClient(app).post("/", headers=_auth("ala", "x"), data={
**FORM, "stations": "true", "tables": "true", "aspect_minor": "true",
"zodiac": "sidereal_lahiri", "house_system": "koch",
"house_systems": ["koch", "campanus"]})
assert r.status_code == 200, r.text[:300]
assert seen["stations"] is False and seen["tables"] is False
assert seen["aspect_minor"] is False and seen["zodiac"] == "tropical"
assert seen["house_system"] == "whole_sign" and seen["house_systems"] == []
def test_extra_charts_are_not_even_in_the_page_source(env, monkeypatch):
"""Nie chodzi o ukrycie rysunków stylem — nie mają w ogóle powstać."""
store.create("ala", "x", ["chart"])
c = _client(monkeypatch)
html = c.post("/", headers=_auth("ala", "x"), data=FORM).text
for word in ("Aspektarian", "deklinacj", "ntyscj"):
assert word not in html, f"źródło strony zdradza „{word}"
def test_the_same_account_with_the_extra_gets_them(env, monkeypatch):
"""Kontrola pozytywna: bez niej powyższy test przechodziłby też wtedy,
gdyby rysunki były zepsute dla wszystkich."""
store.create("ola", "x", ["chart", "extra_charts"])
c = _client(monkeypatch, chart=_chart_with_objects())
html = c.post("/", headers=_auth("ola", "x"), data=FORM).text
assert "Aspektarian" in html
def _chart_with_objects():
"""Horoskop na tyle bogaty, żeby rysunki dodatkowe w ogóle powstały."""
base = _chart()
base["positions"] = [
{"name": n, "glyph": g, "sign": "Aries", "sign_glyph": "", "in_sign": "0",
"decimal": float(i * 30), "direction": "D", "speed": 1.0,
"declination": 10.0, "house": i + 1}
for i, (n, g) in enumerate((("Sun", ""), ("Moon", ""), ("Mars", "")))]
base["aspects"] = [{"obj1": "Sun", "obj2": "Moon", "aspect": "trine",
"orb": 1.0, "allowed": 8.0}]
return base
def test_only_the_administrator_can_manage_accounts(env, monkeypatch):
store.create("ala", "x", ["chart", "ai", "export", "extra_charts"])
c = _client(monkeypatch)
for method, path in (("get", "/accounts"), ("post", "/accounts/create"),
("post", "/accounts/update"), ("post", "/accounts/delete")):
kwargs = {"headers": _auth("ala", "x")}
if method == "post":
kwargs["data"] = {"login": "ktoś"}
r = getattr(c, method)(path, **kwargs)
assert r.status_code == 404, f"{path}{r.status_code}"
assert c.get("/accounts", headers=_auth("szef", "tajne-szefa")).status_code == 200
# ── pełny obieg: założenie konta z ekranu i zalogowanie się na nie ───────
def test_administrator_creates_an_account_and_it_works_immediately(env, monkeypatch):
"""Nagłówek całej funkcji: administrator zakłada konto z ekranu, a osoba na
tym koncie loguje się i dostaje DOKŁADNIE przyznany zestaw — bez restartu
aplikacji i bez dotykania konfiguracji środowiska."""
c = _client(monkeypatch)
admin = _auth("szef", "tajne-szefa")
r = c.post("/accounts/create", headers=admin, follow_redirects=False, data={
"login": "nowa", "password": "jej-hasło", "note": "praktykantka",
"granted": ["chart", "significators", "extra_charts"]})
assert r.status_code == 303
listing = c.get("/accounts", headers=admin).text
assert "nowa" in listing and "praktykantka" in listing
assert "jej-hasło" not in listing, "hasło nie ma prawa trafić na ekran"
her = _auth("nowa", "jej-hasło")
html = c.get("/", headers=her).text
assert 'href="/significators"' in html
assert 'href="/accounts"' not in html and 'href="/compile"' not in html
assert c.get("/compile", headers=her).status_code == 404
# odebranie uprawnienia działa od razu
c.post("/accounts/update", headers=admin, follow_redirects=False,
data={"login": "nowa", "granted": ["chart"]})
assert c.get("/significators", headers=her).status_code == 404
# skasowanie konta odcina logowanie
c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"})
assert c.get("/", headers=her).status_code == 401
def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch):
"""Konto administracyjne nie leży w pliku, więc nie ma czego skasować —
ale próba nie może też wywalić aplikacji ani skasować czegoś innego."""
c = _client(monkeypatch)
admin = _auth("szef", "tajne-szefa")
store.create("ala", "x", ["chart"])
r = c.post("/accounts/delete", headers=admin, follow_redirects=False,
data={"login": "szef"})
assert r.status_code == 303
assert store.exists("ala"), "kasowanie nieistniejącego konta ruszyło inne"
assert c.get("/", headers=admin).status_code == 200
# ── awaria magazynu kont ─────────────────────────────────────────────────
# Ekran kont to JEDYNE miejsce, z którego administrator może naprawić problem
# z magazynem — więc musi na nim przeczytać, co i gdzie jest nie tak. Gołe 500
# (tak było na pierwszym wdrożeniu) zostawia go z niczym.
def _unreadable(tmp_path, monkeypatch, mode):
d = tmp_path / "stan"
d.mkdir()
monkeypatch.setenv("ACCOUNTS_FILE", str(d / "accounts.json"))
d.chmod(mode)
return d
def test_unreadable_store_explains_itself_instead_of_500(env, tmp_path, monkeypatch):
d = _unreadable(tmp_path, monkeypatch, 0o000)
try:
c = _client(monkeypatch)
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
assert r.status_code == 200, "problem z magazynem nie może wywalać strony"
assert "Nie mogę odczytać pliku kont" in r.text
assert str(d / "accounts.json") in r.text, "komunikat ma podać ŚCIEŻKĘ"
finally:
d.chmod(0o755)
def test_read_only_store_refuses_to_save_with_a_reason(env, tmp_path, monkeypatch):
d = _unreadable(tmp_path, monkeypatch, 0o555)
try:
c = _client(monkeypatch)
r = c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
follow_redirects=False,
data={"login": "ala", "password": "x", "granted": ["chart"]})
assert r.status_code == 303
# Komunikat jedzie w parametrze zapytania, więc jest zakodowany —
# porównanie na surowym nagłówku sprawdzałoby procenty, nie treść.
from urllib.parse import unquote_plus
assert "Nie mogę zapisać pliku kont" in unquote_plus(r.headers["location"])
finally:
d.chmod(0o755)
def test_a_corrupt_file_is_never_silently_overwritten(env, tmp_path, monkeypatch):
"""Nadpisanie uszkodzonego pliku pustym zbiorem skasowałoby WSZYSTKIE konta.
Lepiej odmówić i powiedzieć, co jest nie tak."""
path = tmp_path / "accounts.json"
path.write_text("{to nie jest json", encoding="utf-8")
monkeypatch.setenv("ACCOUNTS_FILE", str(path))
c = _client(monkeypatch)
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
assert r.status_code == 200 and "uszkodzony" in r.text
c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
follow_redirects=False, data={"login": "ala", "password": "x"})
assert path.read_text(encoding="utf-8") == "{to nie jest json", \
"uszkodzony plik został nadpisany — konta by zniknęły"
@@ -9,7 +9,16 @@ CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
def test_tab_is_in_the_menu(): def test_tab_is_in_the_menu():
assert 'href="/settings"' in BASE and "Ustawienia" in BASE """Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
from app import features
tab = features.BY_KEY["settings"]
assert tab in features.SCREENS
assert tab.href and tab.label
def test_route_and_client_exist(): def test_route_and_client_exist():
+10 -1
View File
@@ -20,7 +20,16 @@ def _client():
def test_tab_is_in_the_menu(): def test_tab_is_in_the_menu():
assert 'href="/synastry"' in BASE and "Synastria" in BASE """Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
from app import features
tab = features.BY_KEY["synastry"]
assert tab in features.SCREENS
assert tab.href and tab.label
def test_routes_exist(): def test_routes_exist():
@@ -0,0 +1,72 @@
"""Dymki na kosmogramie (PRE-12/PRE-25).
Rysunek niesie <title> przy każdym obiekcie i każdej linii aspektu, ale natywne
dymki przeglądarki przestały się pokazywać po dodaniu powiększania na pełne okno.
Sprawdzone w przeglądarce: elementy z <title> osiągalne kursorem (19 z 20 trafień
w hit-teście), więc nic ich nie zasłaniało problem był w samym natywnym dymku.
Test strukturalny, jak przy wheelzoom: bez uruchamiania przeglądarki nie zbadamy
zachowania JS-a. Pilnujemy więc tego, co da się zepsuć po cichu i co ma znaczenie:
kolejności warstw, zdejmowania i ODWIESZANIA <title> oraz wpięcia skryptu.
Zachowanie sprawdzone ręcznie w przeglądarce przy 1280×900, w obu stanach koła.
"""
import pathlib
import re
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
JS = (APP / "static" / "wheeltip.js").read_text(encoding="utf-8")
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
def test_script_is_loaded_globally():
"""W base.html, bo koło jest też na zakładce „Skompiluj"."""
assert "wheeltip.js" in BASE
def test_tooltip_sits_above_the_zoom_overlay_but_below_the_progress_window():
"""Warstwy: nakładka powiększonego koła < dymek < okno postępu.
Dymek MUSI być nad nakładką, inaczej po powiększeniu czyli dokładnie tam,
gdzie zgłoszono problem byłby niewidoczny. I POD oknem postępu, bo gdy trwa
pisanie horoskopu, log operacji ma zostać na wierzchu."""
tip = re.search(r"\.wheel-tip\s*\{[^}]*z-index:\s*(\d+)", CSS)
zoom = re.search(r"\.wheel-fig\.zoomed\s*\{[^}]*z-index:\s*(\d+)", CSS)
progress = re.search(r"\.progress-overlay\s*\{[^}]*z-index:\s*(\d+)", CSS)
assert tip and zoom and progress, "brakuje którejś reguły z-index"
assert int(zoom.group(1)) < int(tip.group(1)) < int(progress.group(1)), \
f"warstwy: nakładka {zoom.group(1)}, dymek {tip.group(1)}, postęp {progress.group(1)}"
def test_tooltip_never_swallows_the_pointer():
"""Dymek chodzi za kursorem, więc bez pointer-events:none zasłaniałby sam
sobie element, nad którym stoi i migotał."""
rule = re.search(r"\.wheel-tip\s*\{([^}]*)\}", CSS)
assert rule and "pointer-events: none" in rule.group(1)
def test_title_is_detached_only_for_the_hover_and_always_put_back():
"""<title> zostaje w rysunku — to nazwa dostępna dla czytników ekranu.
Na czas najechania odpinamy go, żeby nie było dwóch dymków naraz, ale MUSI
wrócić. Brak odwieszania okradałby czytniki ekranu po pierwszym najechaniu,
i to po cichu na ekranie nic by nie było widać."""
assert "removeChild" in JS, "dymek natywny nie jest tłumiony"
assert "appendChild(stash.node)" in JS, "brak odwieszania <title>"
for leaving in ("mouseleave", "scroll", "resize"):
assert leaving in JS, f"brak sprzątania przy: {leaving}"
def test_tooltip_reads_the_title_that_is_already_in_the_drawing():
"""Źródłem tekstu jest <title> z SVG, nie druga kopia opisów w JS —
inaczej rozjechałyby się przy pierwszej zmianie treści dymka."""
assert ":scope > title" in JS
assert "textContent" in JS
def test_pdf_path_is_untouched():
"""PDF składa się po stronie serwera, więc żaden dymek go nie dotyczy —
<title> zostają w rysunku i nic w chartwheel.py się nie zmienia."""
wheel = (APP / "chartwheel.py").read_text(encoding="utf-8")
assert "<title>" in wheel
assert "wheeltip" not in wheel
+19 -2
View File
@@ -14,8 +14,25 @@ tysiące porównań na całej dziedzinie tak.
**Izolacja jednostki.** Obu implementacjom podajemy TE SAME wejścia przez **Izolacja jednostki.** Obu implementacjom podajemy TE SAME wejścia przez
`swe_houses_armc`. Gdybyśmy porównywali „nasz horoskop" z „horoskopem swissepha", `swe_houses_armc`. Gdybyśmy porównywali „nasz horoskop" z „horoskopem swissepha",
różnica mogłaby wynikać z innego czasu gwiazdowego albo innego ε — czyli z czegoś, różnica mogłaby wynikać z innego czasu gwiazdowego albo innego ε — czyli z czegoś,
co nie jest testowaną funkcją, i utonęlibyśmy w fałszywych alarmach. Czas gwiazdowy co nie jest testowaną funkcją, i utonęlibyśmy w fałszywych alarmach.
i ε mają własny, osobny test (`asc/mc`).
**Czego ten framework NIE sprawdza — i trzeba o tym pamiętać.** Skoro ε podajemy
obu stronom, to **samo ε nie jest tu testowane**. Test `asc/mc` sprawdza wyłącznie
wzory na Ascendent i MC przy zadanym ε, a nie to, czy ε jest właściwe. Kosztowało
to realny błąd: silnik liczył RAMC z GAST (czas gwiazdowy POZORNY, od równonocy
PRAWDZIWEJ), ale parował go z ε ŚREDNIM, bez nutacji. Dwa różne układy odniesienia,
~3,2″ przesunięcia na cuspach — i komplet zielonych przebiegów wyroczni, bo błąd
siedział w danych WEJŚCIOWYCH, nie w testowanej funkcji.
Wejście ma więc własny sprawdzian, poza tym frameworkiem:
`test_sidereal_returns_true_obliquity_not_mean` w `services/logic/tests/`, ze
Skyfieldem jako niezależnym autorytetem (bez swissepha, więc działa wszędzie).
Uwaga pokrewna: porównanie „cały horoskop nasz vs cały horoskop swissepha" zostawia
resztę ~3″ nawet po naprawie ε — to UT1 kontra UTC. Skyfield konwertuje UTC na UT1
z tablic IERS, swisseph przyjmuje podany JD jako UT1 (w 1984-04-30 różnica wynosiła
0,181 s = 2,7″ RAMC). Nasza strona jest tu dokładniejsza. Dokładnie po to porównujemy
przy identycznym RAMC.
**Kryterium, nie procent.** Raportujemy liczbę przypadków **powyżej tolerancji **Kryterium, nie procent.** Raportujemy liczbę przypadków **powyżej tolerancji
(1″)**, **maksymalne odchylenie** i **miejsce** jego wystąpienia oraz histogram. (1″)**, **maksymalne odchylenie** i **miejsce** jego wystąpienia oraz histogram.
+8 -1
View File
@@ -29,6 +29,13 @@ JD_J2000 = 2_451_545.0 # punkt odniesienia — środek dziedziny
# Systemy oparte na łuku dobowym: tracą definicję za kołem podbiegunowym. # Systemy oparte na łuku dobowym: tracą definicję za kołem podbiegunowym.
DIURNAL_ARC_SYSTEMS = frozenset({"placidus", "koch"}) DIURNAL_ARC_SYSTEMS = frozenset({"placidus", "koch"})
# Systemy, w których NASZA dziedzina jest WĘŻSZA niż wyroczni — świadomie, nie przez
# rozjazd. Topocentric: swisseph zwraca powyżej koła podbiegunowego cuspy, które nie
# dzielą okręgu (domy nachodzą na siebie), i sam sobie tam przeczy — swe_house_pos
# nie zgadza się z jego własnymi cuspami. Wolimy odmówić niż podać liczbę bez
# znaczenia, więc harness ma tego NIE zgłaszać jako niezgodności dziedziny.
NARROWER_THAN_ORACLE = frozenset({"topocentric"})
# Jak blisko granicy stawiamy wartości brzegowe (po obu stronach). # Jak blisko granicy stawiamy wartości brzegowe (po obu stronach).
EPSILON_NUDGE = 0.01 EPSILON_NUDGE = 0.01
@@ -65,7 +72,7 @@ def in_domain(system: str, lat: float, jd: float) -> bool:
(i same bieguny, gdzie degeneruje się cała geometria).""" (i same bieguny, gdzie degeneruje się cała geometria)."""
if abs(lat) >= 90.0: if abs(lat) >= 90.0:
return False return False
if system in DIURNAL_ARC_SYSTEMS: if system in DIURNAL_ARC_SYSTEMS or system in NARROWER_THAN_ORACLE:
return abs(lat) < polar_circle(jd) return abs(lat) < polar_circle(jd)
return True return True
+61 -3
View File
@@ -16,11 +16,12 @@ do ścieżki produkcyjnej ani do zależności produktu — izolacja z LOG-27 zos
""" """
from __future__ import annotations from __future__ import annotations
import random
from dataclasses import dataclass, field from dataclasses import dataclass, field
import swisseph as swe import swisseph as swe
from domain import Case, in_domain, obliquity from domain import NARROWER_THAN_ORACLE, Case, in_domain, obliquity
# Litery systemów w Swiss Ephemeris. # Litery systemów w Swiss Ephemeris.
SWE_CODE = { SWE_CODE = {
@@ -110,12 +111,16 @@ def compare_system(system: str, cases: list[Case]) -> Result:
# Zgodność co do DZIEDZINY: jeśli wyrocznia odmawia, a my uważamy, że # Zgodność co do DZIEDZINY: jeśli wyrocznia odmawia, a my uważamy, że
# liczymy — nasza detekcja granicy jest zepsuta (albo odwrotnie). # liczymy — nasza detekcja granicy jest zepsuta (albo odwrotnie).
if (theirs is None) != (not ours_in_domain): # Węższa dziedzina po naszej stronie jest świadoma (patrz NARROWER_THAN_ORACLE),
# więc nie jest rozjazdem. Odwrotność — my liczymy, wyrocznia odmawia — nadal jest.
narrower_on_purpose = (system in NARROWER_THAN_ORACLE
and theirs is not None and not ours_in_domain)
if not narrower_on_purpose and (theirs is None) != (not ours_in_domain):
r.domain_mismatch.append( r.domain_mismatch.append(
f"{c.key()} [{c.label}] — my:{'w dziedzinie' if ours_in_domain else 'poza'}, " f"{c.key()} [{c.label}] — my:{'w dziedzinie' if ours_in_domain else 'poza'}, "
f"wyrocznia:{'odmawia' if theirs is None else 'liczy'}") f"wyrocznia:{'odmawia' if theirs is None else 'liczy'}")
continue continue
if theirs is None: if theirs is None or not ours_in_domain:
r.skipped_out_of_domain += 1 r.skipped_out_of_domain += 1
continue continue
@@ -155,6 +160,59 @@ def histogram(devs: list[float]) -> str:
return "\n".join(out) return "\n".join(out)
# Ile punktów ekliptyki sprawdzamy na jeden zestaw (RAMC, ε, φ).
_PROBES_PER_CASE = 6
def compare_house_assignment(system: str, cases: list[Case]) -> Result:
"""Nasze przypisanie obiektu do domu vs swe_house_pos.
To OSOBNA funkcja od liczenia cuspów i osobny rodzaj błędu: cuspy mogą się
zgadzać co do zera, a planeta i tak wylądować w złym domu. Tak właśnie było
przy |φ| powyżej koła podbiegunowego systemy dzielące koła wielkie mają
kolejność domów ODWRÓCONĄ, a nasze assign_house szło zawsze do przodu.
WYROCZNIA MA TU GRANICĘ WIARYGODNOŚCI. Dla topocentrica swe_house_pos
przestaje być zgodne z WŁASNYMI cuspami swissepha powyżej koła podbiegunowego
(zmierzone: 100% zgodności do 62°, 83,9% przy 66°, ok. 50% przy 72°; dla
regiomontanusa w tych samych punktach 100%). Skoro wyrocznia przeczy sama
sobie, nie może rozstrzygać nie porównujemy tam, zamiast dopisywać wyjątek
bo topocentric". Nasze przypisanie jest w tym obszarze sprawdzane inaczej:
testem samospójności z cuspami (services/logic/tests/test_houses.py).
"""
from app.engine.houses import assign_house, cusps_for, polar_circle
r = Result(system=f"dom:{system}")
rng = random.Random(20260806)
for c in cases:
eps = obliquity(c.jd)
if system == "topocentric" and abs(c.lat) >= polar_circle(eps):
r.skipped_out_of_domain += 1
continue
try:
ours_cusps = cusps_for(c.ramc, eps, c.lat, system)
except Exception:
r.skipped_out_of_domain += 1
continue
for _ in range(_PROBES_PER_CASE):
lon = rng.uniform(0.0, 360.0)
try:
theirs = int(swe.house_pos(c.ramc % 360.0, c.lat, eps,
(lon, 0.0), SWE_CODE[system]))
except Exception:
r.skipped_out_of_domain += 1
continue
r.checked += 1
ours = assign_house(lon, ours_cusps)
if ours != theirs:
r.over_tolerance += 1
r.max_dev = max(r.max_dev, float(abs(ours - theirs)))
if len(r.domain_mismatch) < 5:
r.domain_mismatch.append(
f"{c.key()} lon={lon:.4f} — nasz dom {ours}, wyrocznia {theirs}")
return r
def format_report(results: list[Result], seed: int | None = None) -> str: def format_report(results: list[Result], seed: int | None = None) -> str:
lines = ["", "=" * 78, lines = ["", "=" * 78,
"ZGODNOŚĆ Z WYROCZNIĄ (Swiss Ephemeris) — domy astrologiczne", "ZGODNOŚĆ Z WYROCZNIĄ (Swiss Ephemeris) — domy astrologiczne",
+4 -1
View File
@@ -22,7 +22,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from domain import boundary_cases, interior_cases, random_cases # noqa: E402 from domain import boundary_cases, interior_cases, random_cases # noqa: E402
from harness import ( # noqa: E402 from harness import ( # noqa: E402
SWE_CODE, compare_asc_mc, compare_system, format_report, SWE_CODE, compare_asc_mc, compare_house_assignment, compare_system,
format_report,
) )
# Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) — # Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) —
@@ -57,6 +58,8 @@ def main() -> int:
results = [compare_asc_mc(cases)] results = [compare_asc_mc(cases)]
results += [compare_system(s, cases) for s in systems] results += [compare_system(s, cases) for s in systems]
# Przypisanie obiektu do domu — osobny rodzaj błędu niż same cuspy.
results += [compare_house_assignment(s, cases) for s in systems]
print(f"\nprzypadków wejściowych: {len(cases)} (tryb: {args.mode})") print(f"\nprzypadków wejściowych: {len(cases)} (tryb: {args.mode})")
print(format_report(results, seed=seed_note)) print(format_report(results, seed=seed_note))