Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a7f7e1b13 | |||
| a833965909 | |||
| baf4e0e38a | |||
| 8b6ecc727d | |||
| 21a00b0000 | |||
| 4d1daab35a | |||
| aec3f84331 | |||
| 7266f671a4 | |||
| 548d9301f3 | |||
| 1be57a47d8 | |||
| 40f5e459e0 | |||
| 86a0f16f9e | |||
| e3114f3e7c | |||
| bc80745a94 | |||
| 6e7cfcbbd7 | |||
| 70c83cfc0c | |||
| 4c1e7f8808 |
@@ -0,0 +1,33 @@
|
|||||||
|
# Kontekst builda dla obrazów budowanych z KORZENIA repo (dziś: pomocniczy obraz
|
||||||
|
# wyroczni w CI — patrz .gitea/workflows/tests.yml). Obrazy usług mają własne
|
||||||
|
# konteksty (services/<usługa>), więc ten plik ich nie dotyczy.
|
||||||
|
#
|
||||||
|
# UWAGA: docker NIE czyta .gitignore. Bez tego pliku do demona poleciałby m.in.
|
||||||
|
# lokalny wirtualenv (~344 MB) i jądra efemeryd — a runner miał już incydent
|
||||||
|
# „no space left on device".
|
||||||
|
|
||||||
|
# środowiska lokalne
|
||||||
|
.env/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# cache Pythona i narzędzi
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# dane generowane/pobierane, odtwarzalne
|
||||||
|
**/.ephemeris/
|
||||||
|
**/.cache/
|
||||||
|
|
||||||
|
# historia i metadane repo
|
||||||
|
.git/
|
||||||
|
.gitea/
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# rzeczy nieużywane w obrazach
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
@@ -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
|
||||||
@@ -138,9 +138,68 @@ 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
|
||||||
|
|
||||||
|
# Zgodność naszych domów z wyrocznią (Swiss Ephemeris). Odpalamy WEWNĄTRZ
|
||||||
|
# obrazu silnika B — tylko tam jest pyswisseph — montując naszą warstwę
|
||||||
|
# logiczną i framework. Bez klastra i bez HTTP: to czysta funkcja.
|
||||||
|
# BLOKUJE build: błędny system domów jest CICHY (wykres wygląda dobrze,
|
||||||
|
# planety siedzą w złych domach), więc lepiej zatrzymać go przed wypuszczeniem
|
||||||
|
# niż wykryć po fakcie.
|
||||||
|
- name: Domy — zgodność z wyrocznią (brzegi + wnętrze)
|
||||||
|
run: |
|
||||||
|
# Pliki WBUDOWUJEMY w obraz, a nie montujemy przez -v. Powód ten sam,
|
||||||
|
# dla którego wyżej nie startujemy kontenera w tle: job Gitea Actions sam
|
||||||
|
# działa w kontenerze, więc `-v $PWD/...` docker rozwiązuje na HOŚCIE,
|
||||||
|
# gdzie tej ścieżki nie ma. Docker nie zgłasza wtedy błędu — po cichu
|
||||||
|
# tworzy PUSTY katalog, przez co skrypt „znika". Kontekst builda jest
|
||||||
|
# strumieniowany do demona, więc działa niezależnie od tego, gdzie on stoi.
|
||||||
|
docker build -t astrololo/oracle:ci -f - . <<'DOCKERFILE'
|
||||||
|
FROM astrololo/engine-swisseph:ci
|
||||||
|
COPY services/logic /logic
|
||||||
|
COPY tests/oracle /oracle
|
||||||
|
ENV LOGIC_PATH=/logic
|
||||||
|
DOCKERFILE
|
||||||
|
docker run --rm astrololo/oracle:ci python /oracle/run.py --mode build
|
||||||
|
|
||||||
|
# Obraz pomocniczy nie jest już potrzebny — a runner miał już incydent
|
||||||
|
# „no space left on device", więc sprzątamy po sobie od razu.
|
||||||
|
- name: Usuń obraz pomocniczy wyroczni
|
||||||
|
if: always()
|
||||||
|
run: docker rmi -f astrololo/oracle:ci 2>/dev/null || true
|
||||||
|
|
||||||
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
|
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
|
||||||
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
|
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
|
||||||
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
|
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,195 @@
|
|||||||
|
# DAN-25 — zamknięcie dostępu do baz na NFS (TrueNAS SCALE)
|
||||||
|
|
||||||
|
Bazy interpretacyjne leżą na `192.168.1.34:/mnt/Tank1/astrololo`. Dziś udział jest
|
||||||
|
osiągalny z całej sieci, więc **kto ma dostęp do LAN, bierze kompletne bazy
|
||||||
|
w oryginale — z pominięciem logowania, limitów, audytu i canary**. Żadne
|
||||||
|
zabezpieczenie w kodzie tego nie zamyka: to najkrótsza droga do wycieku.
|
||||||
|
|
||||||
|
Cel: udział `astrololo` widoczny **tylko dla trzech węzłów k3s**, **tylko do
|
||||||
|
odczytu**, z **root_squash**.
|
||||||
|
|
||||||
|
## ⚠️ Zasada nadrzędna: ruszamy WYŁĄCZNIE udział astrololo
|
||||||
|
|
||||||
|
Tank1 obsługuje cały homelab — Proxmox (`proxmox-NFS`), conjurera (`conjurer_swap`,
|
||||||
|
z **zapisem**), media, LXC-e, stację roboczą. **Nie dotykamy globalnych ustawień
|
||||||
|
usługi NFS ani innych udziałów** — inaczej wywalimy VM-y, bota i bibliotekę mediów.
|
||||||
|
Każda komenda niżej celuje w jeden konkretny udział.
|
||||||
|
|
||||||
|
Druga zasada: **w TrueNAS SCALE nie edytuje się `/etc/exports` ręcznie.** Plik
|
||||||
|
generuje middleware i nadpisze każdą ręczną zmianę. Wszystko robimy przez `midclt`
|
||||||
|
(albo GUI: *Shares → Unix (NFS) Shares*).
|
||||||
|
|
||||||
|
## Ustalone dane
|
||||||
|
|
||||||
|
| Co | Wartość |
|
||||||
|
|---|---|
|
||||||
|
| NAS | `192.168.1.34` (TrueNAS SCALE / Community Edition) |
|
||||||
|
| Udział do zamknięcia | `/mnt/Tank1/astrololo` |
|
||||||
|
| Węzły k3s (jedyni uprawnieni) | `192.168.1.73` (server), `192.168.1.80` (agent2), `192.168.1.81` (agent1) |
|
||||||
|
| Kto montuje astrololo | wyłącznie pod `data` w ns `astrololo`, **read-only** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faza 0 — rozpoznanie (nic nie zmienia)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh admin@192.168.1.34
|
||||||
|
```
|
||||||
|
|
||||||
|
Wersja systemu (potwierdza, że komendy niżej pasują):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
midclt call system.version
|
||||||
|
```
|
||||||
|
|
||||||
|
Lista udziałów NFS z ich obecnymi ustawieniami — **stąd bierzemy ID udziału astrololo**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
midclt call sharing.nfs.query | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
> W wyniku poszukaj wpisu ze ścieżką `/mnt/Tank1/astrololo` i zapamiętaj jego `id`.
|
||||||
|
> **Sprawdź też, jak nazywają się pola** (`path` vs `paths`, `hosts`, `networks`,
|
||||||
|
> `ro`, `maproot_user`, `mapall_user`) — middleware zmieniało ich nazwy między
|
||||||
|
> wersjami SCALE. Dalsze komendy używają nazw z Twojego wyniku.
|
||||||
|
|
||||||
|
Kto jest teraz podłączony (żeby nie odciąć czegoś w trakcie pracy):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ss -tn state established '( sport = :2049 )'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Faza 1 — dowód dziury (zrób PRZED zmianą)
|
||||||
|
|
||||||
|
Na **stacji roboczej** (Mac mini, czyli host spoza klastra):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/nfs-test && sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la /tmp/nfs-test | head
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeśli widzisz pliki baz — **to jest dokładnie problem, który zamykamy**. Odmontuj:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo umount /tmp/nfs-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Faza 2 — kopia obecnej konfiguracji (możliwość cofnięcia)
|
||||||
|
|
||||||
|
Na NAS-ie, podstaw `<ID>` z Fazy 0:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
midclt call sharing.nfs.query '[["id","=",<ID>]]' > /root/astrololo-nfs-share.backup.json && cat /root/astrololo-nfs-share.backup.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Faza 3 — zawężenie udziału
|
||||||
|
|
||||||
|
Jedna komenda ustawia wszystkie trzy zabezpieczenia naraz: listę hostów, tylko
|
||||||
|
odczyt i root_squash. Podstaw `<ID>`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
midclt call sharing.nfs.update <ID> '{"hosts": ["192.168.1.73", "192.168.1.80", "192.168.1.81"], "ro": true, "maproot_user": null, "maproot_group": null, "mapall_user": null, "mapall_group": null}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Co robi każdy element:
|
||||||
|
|
||||||
|
| Ustawienie | Znaczenie |
|
||||||
|
|---|---|
|
||||||
|
| `hosts` | eksport **tylko** dla trzech węzłów k3s — reszta LAN przestaje widzieć udział |
|
||||||
|
| `ro: true` | tylko odczyt; aplikacja i tak montuje read-only, więc niczego nie łamie |
|
||||||
|
| `maproot_*`, `mapall_*` = `null` | **root_squash**: root z klienta nie jest rootem na udziale |
|
||||||
|
|
||||||
|
Zastosuj i sprawdź, że middleware przepisał eksporty:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
midclt call service.reload nfs && exportfs -v | grep -A1 astrololo
|
||||||
|
```
|
||||||
|
|
||||||
|
> Jeśli Twoja wersja nie ma `service.reload`, użyj GUI (*Shares → NFS → zapisz*),
|
||||||
|
> co wymusi to samo.
|
||||||
|
|
||||||
|
## Faza 4 — weryfikacja (wszystkie cztery testy)
|
||||||
|
|
||||||
|
**1. Spoza klastra ma NIE działać.** Na Macu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Oczekiwane: `access denied` / `Operation not permitted`. **Jeśli montuje się dalej —
|
||||||
|
zmiana nie zadziałała, nie idź dalej.**
|
||||||
|
|
||||||
|
**2. Z węzła klastra ma działać.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh 192.168.1.73 'sudo mount -t nfs -o ro 192.168.1.34:/mnt/Tank1/astrololo /mnt/test && ls /mnt/test | head -3 && sudo umount /mnt/test'
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Aplikacja żyje.** Pody muszą wstać i realnie czytać bazy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo rollout restart deploy/data && kubectl -n astrololo rollout status deploy/data
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo exec deploy/data -- ls /app/data_files | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Reszta homelabu nietknięta** — conjurer (zapis!) i pozostałe udziały:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n conjurer get pods
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
midclt call sharing.nfs.query | python3 -c "import sys,json;[print(s.get('path') or s.get('paths'), '| hosts:', s.get('hosts'), '| ro:', s.get('ro')) for s in json.load(sys.stdin)]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Oczekiwane: **tylko** astrololo ma zawężone `hosts` i `ro: true`; reszta bez zmian.
|
||||||
|
|
||||||
|
## Faza 5 — wycofanie (gdyby coś padło)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
midclt call sharing.nfs.update <ID> '{"hosts": [], "ro": false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
midclt call service.reload nfs
|
||||||
|
```
|
||||||
|
|
||||||
|
To przywraca poprzedni stan (pełna kopia w `/root/astrololo-nfs-share.backup.json`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pułapki, o których warto wiedzieć
|
||||||
|
|
||||||
|
**Hookscript Proxmoxa.** Na Proxmoxie działa `wait-truenas.sh`, który przed startem
|
||||||
|
VM czeka w pętli na `showmount -e 192.168.1.34`. Zawężamy tylko udział astrololo,
|
||||||
|
więc `showmount` nadal zwróci pozostałe eksporty i pętla przejdzie. Mimo to sprawdź
|
||||||
|
po zmianie:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@pve2 'showmount -e 192.168.1.34'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aktualizacja baz przestanie działać przez NFS.** Po `ro: true` nikt nie wgra
|
||||||
|
nowych plików baz przez ten udział — również Ty. Do wgrywania użyj GUI TrueNAS,
|
||||||
|
SMB albo SSH bezpośrednio na NAS-ie. To celowe: udział ma być drogą tylko do
|
||||||
|
czytania przez aplikację.
|
||||||
|
|
||||||
|
**`hosts` przyjmuje adresy IP, nie nazwy** — świadomie, żeby dostęp nie zależał od
|
||||||
|
DNS-u (AdGuard na `.57`). Gdyby doszedł czwarty węzeł k3s, trzeba dopisać jego IP,
|
||||||
|
inaczej pod `data` na nim nie wstanie.
|
||||||
|
|
||||||
|
**To nie jest uwierzytelnianie.** Lista IP zatrzymuje przypadkowy i oportunistyczny
|
||||||
|
dostęp, ale adres da się podszyć w tej samej sieci. Docelowo (poza zakresem tego
|
||||||
|
kroku): NFSv4 + Kerberos albo przeniesienie plików na wolumen nieosiągalny poza
|
||||||
|
klastrem — tak mówi samo wymaganie DAN-25.
|
||||||
|
|
||||||
|
## Po wykonaniu
|
||||||
|
|
||||||
|
Zaktualizuj status DAN-25 w `docs/astrololo_wymagania.xlsx` na **Zrobione** (albo
|
||||||
|
**W trakcie**, jeśli zostawiasz Kerberosa jako etap docelowy).
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# LOG-33 — sekrety w spoczynku i procedura rotacji
|
||||||
|
|
||||||
|
Sekrety (`APP_PASSWORD`/`APP_USERS`, `INTERNAL_TOKEN`, klucze łącz AES, klucze API
|
||||||
|
do dostawców LLM) trafiają do obiektów Secret w Kubernetesie, gdzie domyślnie są
|
||||||
|
**tylko zakodowane base64** — jawne dla każdego, kto przeczyta magazyn stanu k3s
|
||||||
|
albo ma prawo odczytu sekretów w namespace.
|
||||||
|
|
||||||
|
Ten dokument opisuje: **macierz rotacji** (co restartować przy zmianie czego),
|
||||||
|
**procedury rotacji per sekret** i **kroki hartowania**, które wymagają dostępu do
|
||||||
|
węzła.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Macierz zależności — kto używa którego sekretu
|
||||||
|
|
||||||
|
Wyliczona z żywych deploymentów, nie z założeń:
|
||||||
|
|
||||||
|
| Sekret / klucz | Usługi, które go czytają | Restart obejmuje |
|
||||||
|
|---|---|---|
|
||||||
|
| `astrololo-auth` / **`INTERNAL_TOKEN`** | data, logic, presentation, **render** | **wszystkie cztery, równocześnie** |
|
||||||
|
| `astrololo-auth` / `APP_PASSWORD`, `APP_USERS` | presentation | tylko presentation |
|
||||||
|
| `astrololo-link` / `LINK_KEY_LOGIC_DATA` | logic, data | **para**: logic + data |
|
||||||
|
| `astrololo-link` / `LINK_KEY_PRESENTATION_LOGIC` | presentation, logic | **para**: presentation + logic |
|
||||||
|
| `astrololo-link` / `LINK_KEY_PRESENTATION_RENDER` | presentation, render | **para**: presentation + render |
|
||||||
|
| `astrololo-llm` / `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | logic | tylko logic |
|
||||||
|
|
||||||
|
> **UWAGA — częsty błąd.** Wcześniejsza wersja tego wymagania mówiła o „wszystkich
|
||||||
|
> **trzech** usługach" przy `INTERNAL_TOKEN`. To już nieprawda: `render` (PRE-24)
|
||||||
|
> również go używa. Restart trzech zostawi render ze starym tokenem i **usługa po
|
||||||
|
> cichu przestanie się dogadywać** — dokładnie ta awaria, przed którą wymaganie
|
||||||
|
> ostrzega.
|
||||||
|
|
||||||
|
Klucze łącz są **parami** — rotacja jednego wymaga restartu tylko dwóch usług, nie
|
||||||
|
całej czwórki. W trakcie wymiany para chwilowo się nie dogaduje (klucze muszą być
|
||||||
|
zgodne po obu stronach łącza), dlatego restart obu naraz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Procedury rotacji
|
||||||
|
|
||||||
|
### A. Klucze LLM (najbezpieczniejsze do przećwiczenia)
|
||||||
|
|
||||||
|
Dotykają wyłącznie logiki, a awaria jest widoczna od razu i nieszkodliwa
|
||||||
|
(niedostępna chmura, model lokalny działa dalej). **Zacznij ćwiczenie od tego.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
read -rs -p "Nowy ANTHROPIC_API_KEY: " NEW; echo
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo create secret generic astrololo-llm \
|
||||||
|
--from-literal=OPENAI_API_KEY="$(kubectl -n astrololo get secret astrololo-llm -o jsonpath='{.data.OPENAI_API_KEY}' | base64 -d)" \
|
||||||
|
--from-literal=ANTHROPIC_API_KEY="$NEW" \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unset NEW && kubectl -n astrololo rollout restart deploy/logic && kubectl -n astrololo rollout status deploy/logic
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. Klucz łącza (para usług)
|
||||||
|
|
||||||
|
Przykład dla `LINK_KEY_PRESENTATION_RENDER`. Pozostałe klucze zachowujemy bez zmian,
|
||||||
|
odczytując je z istniejącego sekretu — inaczej skasowalibyśmy pozostałe łącza.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo create secret generic astrololo-link \
|
||||||
|
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
|
||||||
|
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
|
||||||
|
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo rollout restart deploy/presentation deploy/render
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. `INTERNAL_TOKEN` (wszystkie cztery naraz)
|
||||||
|
|
||||||
|
Najbardziej wrażliwa rotacja: w trakcie usługi z różnymi tokenami **odrzucają się
|
||||||
|
nawzajem**, więc restart musi objąć całą czwórkę.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo create secret generic astrololo-auth \
|
||||||
|
--from-literal=APP_PASSWORD="$(kubectl -n astrololo get secret astrololo-auth -o jsonpath='{.data.APP_PASSWORD}' | base64 -d)" \
|
||||||
|
--from-literal=INTERNAL_TOKEN="$(openssl rand -hex 32)" \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo rollout restart deploy/data deploy/logic deploy/presentation deploy/render
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo rollout status deploy/data && kubectl -n astrololo rollout status deploy/logic && kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
|
||||||
|
```
|
||||||
|
|
||||||
|
> Jeśli używasz kont imiennych (PRE-17), zamiast `APP_PASSWORD` zachowaj `APP_USERS`
|
||||||
|
> — patrz [`konta-i-audyt.md`](konta-i-audyt.md).
|
||||||
|
|
||||||
|
### Weryfikacja po KAŻDEJ rotacji
|
||||||
|
|
||||||
|
Sam `Running` nie wystarczy — pody wstaną nawet, gdy warstwy się nie dogadują.
|
||||||
|
Trzeba sprawdzić **realny przelot przez wszystkie łącza**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get pods
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "aplikacja: %{http_code}\n" -u "<login>:<hasło>" https://astrololo.czernobog.pl/
|
||||||
|
```
|
||||||
|
|
||||||
|
Policz horoskop w przeglądarce (dotyka presentation→logic→data) i wygeneruj PDF
|
||||||
|
(dotyka presentation→render). Dopiero to potwierdza, że wszystkie cztery klucze
|
||||||
|
i token są spójne.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hartowanie — kroki wymagające dostępu do węzła
|
||||||
|
|
||||||
|
### 1. Szyfrowanie sekretów w spoczynku (najważniejsze)
|
||||||
|
|
||||||
|
k3s z jednym serwerem trzyma stan w **SQLite**, nie w etcd, więc „szyfrowanie etcd"
|
||||||
|
sprowadza się do wbudowanej funkcji k3s. Kroki są w sekcji „Co musisz zrobić sam"
|
||||||
|
poniżej. Efekt: kopia pliku stanu albo snapshot VM przestaje być wyciekiem haseł
|
||||||
|
i kluczy API.
|
||||||
|
|
||||||
|
**Granica:** klucz szyfrujący leży na tym samym serwerze. Chroni przed kradzieżą
|
||||||
|
pliku/snapshotu — nie przed kimś, kto ma roota na węźle.
|
||||||
|
|
||||||
|
### 2. Ograniczenie tokenów kont serwisowych ✅
|
||||||
|
|
||||||
|
Zrobione: `automountServiceAccountToken: false` we wszystkich czterech usługach
|
||||||
|
(deploy #13). Żadna nie rozmawia z API Kubernetesa — sekrety wstrzykuje kubelet,
|
||||||
|
nie pod — więc token był zbędny, a stanowił gotowy punkt wyjścia do klastra.
|
||||||
|
|
||||||
|
### 3. RBAC
|
||||||
|
|
||||||
|
Stan sprawdzony: **zero RoleBindings** w `astrololo`, `cluster-admin` tylko dla
|
||||||
|
`system:masters` i dwóch kont Helma w `kube-system`. Nie ma rozdanych nadmiarowych
|
||||||
|
uprawnień do cofania.
|
||||||
|
|
||||||
|
Realna ekspozycja to **kubeconfig admina**. Sensowny krok: osobny, ograniczony
|
||||||
|
kubeconfig do codziennej pracy, a admin tylko wtedy, gdy naprawdę potrzebny.
|
||||||
|
|
||||||
|
### 4. Sealed Secrets / SOPS — świadomie ODŁOŻONE
|
||||||
|
|
||||||
|
Dziś sekrety tworzone są ręcznie i **nie ma ich w repo GitOps** — czyli zasada
|
||||||
|
„nie wpisywać sekretów do repozytorium" **jest już spełniona**. Kosztem jest
|
||||||
|
odtwarzalność: po utracie klastra nikt nie wie, co tam było.
|
||||||
|
|
||||||
|
Sealed Secrets pozwoliłoby trzymać je w gicie w postaci zaszyfrowanej, ale to
|
||||||
|
zmiana filozofii i **nowy pojedynczy punkt awarii**: utrata klucza kontrolera =
|
||||||
|
utrata wszystkich sekretów. Rekomendacja: dopiero po punkcie 1, i tylko jeśli
|
||||||
|
zależy Ci na odtwarzalności klastra z gita.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Czego NIE robimy
|
||||||
|
|
||||||
|
- **Nie wpisujemy sekretów do logów.** Dziennik audytowy (PRE-17) niesie wyłącznie
|
||||||
|
metadane i liczby.
|
||||||
|
- **Nie wpisujemy sekretów do repo GitOps.** Manifesty odwołują się do sekretów
|
||||||
|
przez `secretKeyRef` i celowo nie zawierają wartości.
|
||||||
|
- **Nie zostawiamy haseł w historii powłoki** — stąd `read -rs` i odczyt istniejących
|
||||||
|
wartości przez `kubectl … | base64 -d` zamiast wpisywania ich ponownie.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Przegląd baz na udziale i ich globalne włączanie/wyłączanie (DAN-15 / PRE-09).
|
||||||
|
|
||||||
|
Wymaganie przedefiniowane pod model serwerowy: pliki baz leżą na stałym NFS, więc
|
||||||
|
nie wybiera się folderu — potrzeba za to WIDZIEĆ, jakie bazy są dostępne (nazwa +
|
||||||
|
metaopis) i móc globalnie zdecydować, które biorą udział w interpretacji.
|
||||||
|
|
||||||
|
Stan przełączników trzymamy DEKLARATYWNIE w zmiennej `DISABLED_BASES`, a nie w
|
||||||
|
pliku, bo warstwa danych nie ma gdzie trwale zapisywać: udział z bazami jest
|
||||||
|
montowany read-only, a katalog cache to `emptyDir` (ginie przy restarcie poda).
|
||||||
|
Zapis do pliku po cichu wracałby więc do stanu sprzed restartu — a ciche
|
||||||
|
przywrócenie wyłączonej bazy jest gorsze niż konieczność edycji konfiguracji.
|
||||||
|
|
||||||
|
Dopasowanie jest tolerancyjne: wpis pasuje po nazwie pliku ALBO po ścieżce
|
||||||
|
względnej — żeby dało się wyłączyć zarówno „stara_baza.xlsx", jak i
|
||||||
|
„archiwum/stara_baza.xlsx".
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def disabled_entries() -> list[str]:
|
||||||
|
return [e.strip() for e in os.getenv("DISABLED_BASES", "").split(",") if e.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _relative(path: Path, root: Path) -> str:
|
||||||
|
try:
|
||||||
|
return str(path.relative_to(root))
|
||||||
|
except ValueError:
|
||||||
|
return path.name
|
||||||
|
|
||||||
|
|
||||||
|
def is_enabled(path: str | Path, root: str | Path, entries: list[str] | None = None) -> bool:
|
||||||
|
"""Czy baza bierze udział w wyszukiwaniu interpretacji."""
|
||||||
|
entries = disabled_entries() if entries is None else entries
|
||||||
|
if not entries:
|
||||||
|
return True
|
||||||
|
p = Path(path)
|
||||||
|
rel = _relative(p, Path(root))
|
||||||
|
return not any(e == p.name or e == rel for e in entries)
|
||||||
|
|
||||||
|
|
||||||
|
def list_bases(root: str | Path, paths: list[str]) -> list[dict]:
|
||||||
|
"""Bazy dostępne na udziale + metaopis. Celowo TANI opis (dane z systemu
|
||||||
|
plików): przy setkach plików liczenie rekordów oznaczałoby wczytanie każdego."""
|
||||||
|
root = Path(root)
|
||||||
|
entries = disabled_entries()
|
||||||
|
out: list[dict] = []
|
||||||
|
for raw in paths:
|
||||||
|
p = Path(raw)
|
||||||
|
try:
|
||||||
|
st = p.stat()
|
||||||
|
size_mb, modified = round(st.st_size / (1024 * 1024), 2), st.st_mtime
|
||||||
|
except OSError:
|
||||||
|
size_mb, modified = None, None
|
||||||
|
out.append({
|
||||||
|
"name": p.name,
|
||||||
|
"path": _relative(p, root),
|
||||||
|
"size_mb": size_mb,
|
||||||
|
"modified": (datetime.fromtimestamp(modified, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
if modified else None),
|
||||||
|
"enabled": is_enabled(p, root, entries),
|
||||||
|
})
|
||||||
|
return out
|
||||||
@@ -44,6 +44,16 @@ def search(query: SearchQuery) -> SearchResult:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/bases")
|
||||||
|
def bases() -> dict:
|
||||||
|
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||||
|
|
||||||
|
Same METADANE — nazwy plików, rozmiar, data. Żadnej treści baz, więc podgląd
|
||||||
|
listy nie jest kolejną drogą do ich wyniesienia."""
|
||||||
|
items = provider.list_bases()
|
||||||
|
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=HealthInfo)
|
@app.get("/health", response_model=HealthInfo)
|
||||||
def health() -> HealthInfo:
|
def health() -> HealthInfo:
|
||||||
return provider.health()
|
return provider.health()
|
||||||
|
|||||||
@@ -25,3 +25,10 @@ class DataProvider(ABC):
|
|||||||
def warmup(self) -> None:
|
def warmup(self) -> None:
|
||||||
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
|
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def list_bases(self) -> list[dict]:
|
||||||
|
"""Bazy widoczne dla dostawcy + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||||
|
|
||||||
|
Opcjonalne: dostawca SQL nie operuje na plikach, więc domyślnie pusto —
|
||||||
|
UI pokaże wtedy, że nie ma czego przełączać, zamiast się wywrócić."""
|
||||||
|
return []
|
||||||
|
|||||||
@@ -91,10 +91,31 @@ class ExcelDataProvider(DataProvider):
|
|||||||
base = Path(self.s.excel_dir)
|
base = Path(self.s.excel_dir)
|
||||||
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
|
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
|
||||||
|
|
||||||
|
def _enabled_files(self, paths: list[str]) -> list[str]:
|
||||||
|
"""Odsiewa bazy WYŁĄCZONE globalnie (DAN-15) — nie biorą udziału
|
||||||
|
w interpretacji, choć fizycznie leżą na udziale."""
|
||||||
|
from app import bases
|
||||||
|
|
||||||
|
entries = bases.disabled_entries()
|
||||||
|
if not entries:
|
||||||
|
return paths
|
||||||
|
return [p for p in paths if bases.is_enabled(p, self.s.excel_dir, entries)]
|
||||||
|
|
||||||
|
def list_bases(self) -> list[dict]:
|
||||||
|
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
|
||||||
|
from app import bases
|
||||||
|
|
||||||
|
return bases.list_bases(self.s.excel_dir, self._excel_files())
|
||||||
|
|
||||||
# ---- publiczne API ----
|
# ---- publiczne API ----
|
||||||
def search(self, query: SearchQuery) -> SearchResult:
|
def search(self, query: SearchQuery) -> SearchResult:
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
|
# Lista wyłączonych baz wchodzi do klucza cache: bez tego zmiana ustawień
|
||||||
|
# oddawałaby wynik sprzed zmiany, czyli treść bazy uznanej za wyłączoną.
|
||||||
|
from app import bases
|
||||||
|
|
||||||
|
disabled = ",".join(bases.disabled_entries())
|
||||||
|
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}|{disabled}"
|
||||||
|
|
||||||
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
|
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
@@ -105,6 +126,9 @@ class ExcelDataProvider(DataProvider):
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
|
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
|
||||||
candidates = [(p, "0") for p in self._excel_files()]
|
candidates = [(p, "0") for p in self._excel_files()]
|
||||||
|
# bazy wyłączone globalnie (DAN-15) pomijamy niezależnie od źródła kandydatów
|
||||||
|
allowed = set(self._enabled_files([p for p, _ in candidates]))
|
||||||
|
candidates = [(p, s) for p, s in candidates if p in allowed]
|
||||||
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
for path, _sheet in candidates:
|
for path, _sheet in candidates:
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Przegląd baz na udziale i ich globalne wyłączanie (DAN-15 / PRE-09)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import bases
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_env(monkeypatch):
|
||||||
|
monkeypatch.delenv("DISABLED_BASES", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = "/dane"
|
||||||
|
PATHS = [f"{ROOT}/main_base.xlsx", f"{ROOT}/archiwum/stara.xlsx"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_everything_enabled_by_default():
|
||||||
|
for b in bases.list_bases(ROOT, PATHS):
|
||||||
|
assert b["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_by_file_name(monkeypatch):
|
||||||
|
monkeypatch.setenv("DISABLED_BASES", "stara.xlsx")
|
||||||
|
state = {b["name"]: b["enabled"] for b in bases.list_bases(ROOT, PATHS)}
|
||||||
|
assert state == {"main_base.xlsx": True, "stara.xlsx": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_by_relative_path(monkeypatch):
|
||||||
|
"""Wpis może wskazywać ścieżkę względną, nie tylko samą nazwę."""
|
||||||
|
monkeypatch.setenv("DISABLED_BASES", "archiwum/stara.xlsx")
|
||||||
|
assert bases.is_enabled(f"{ROOT}/archiwum/stara.xlsx", ROOT) is False
|
||||||
|
assert bases.is_enabled(f"{ROOT}/main_base.xlsx", ROOT) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_entries_are_trimmed_and_multiple(monkeypatch):
|
||||||
|
monkeypatch.setenv("DISABLED_BASES", " stara.xlsx , main_base.xlsx ")
|
||||||
|
assert [b["enabled"] for b in bases.list_bases(ROOT, PATHS)] == [False, False]
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_carries_metadata_not_content(tmp_path):
|
||||||
|
"""Metaopis: nazwa, ścieżka, rozmiar, data — ŻADNEJ treści bazy."""
|
||||||
|
f = tmp_path / "baza.xlsx"
|
||||||
|
f.write_bytes(b"x" * 2048)
|
||||||
|
item = bases.list_bases(tmp_path, [str(f)])[0]
|
||||||
|
assert set(item) == {"name", "path", "size_mb", "modified", "enabled"}
|
||||||
|
assert item["name"] == "baza.xlsx" and item["size_mb"] is not None and item["modified"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_file_does_not_crash_the_listing():
|
||||||
|
item = bases.list_bases(ROOT, [f"{ROOT}/nie-ma.xlsx"])[0]
|
||||||
|
assert item["size_mb"] is None and item["modified"] is None
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ class DataClient:
|
|||||||
payload=payload, headers=_auth_headers(),
|
payload=payload, headers=_auth_headers(),
|
||||||
link=_link())
|
link=_link())
|
||||||
|
|
||||||
|
def bases(self) -> dict[str, Any]:
|
||||||
|
"""Lista baz na udziale + metaopis (DAN-15) — same metadane, bez treści."""
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
||||||
|
headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
def health(self) -> dict[str, Any]:
|
def health(self) -> dict[str, Any]:
|
||||||
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
||||||
# nie mają, a nie przechodzi tędy nic z baz.
|
# nie mają, a nie przechodzi tędy nic z baz.
|
||||||
|
|||||||
@@ -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.
|
||||||
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
# ε PRAWDZIWE — to samo, którym liczymy domy, bo pozycje ze Skyfielda są
|
||||||
|
# POZORNE (uwzględniają nutację). Silnik bez sidereal() nutacji nie poda;
|
||||||
|
# wtedy zostaje średnie, z jawnym oznaczeniem w wyniku.
|
||||||
|
if hasattr(engine, "sidereal"):
|
||||||
|
eps = engine.sidereal(moment)[1]
|
||||||
|
result["obliquity_kind"] = "true"
|
||||||
|
else:
|
||||||
|
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
||||||
|
result["obliquity_kind"] = "mean"
|
||||||
result["obliquity"] = round(eps, 6)
|
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)
|
||||||
@@ -97,7 +105,11 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
asc = H.compute_asc(ramc, eps, moment.lat)
|
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||||
mc = H.compute_mc(ramc, eps)
|
mc = H.compute_mc(ramc, eps)
|
||||||
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||||
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
|
# cusps_detailed, nie cusps_for: Placidus i Koch nie istnieją powyżej koła
|
||||||
|
# podbiegunowego, a astrolog z Tromsø ma dostać wynik ZE ŚLADEM, czym go
|
||||||
|
# policzyliśmy. Ten ślad musi dojść aż do raportu i PDF-a.
|
||||||
|
primary = H.cusps_detailed(ramc, eps, moment.lat, system)
|
||||||
|
cusp_list = primary.cusps # tropikalne — geometria domów jest niezmiennicza
|
||||||
|
|
||||||
def _cusps_out(cl: list[float]) -> list[dict]:
|
def _cusps_out(cl: list[float]) -> list[dict]:
|
||||||
"""Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif."""
|
"""Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif."""
|
||||||
@@ -109,7 +121,11 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
for i, c in enumerate(cl)
|
for i, c in enumerate(cl)
|
||||||
]
|
]
|
||||||
|
|
||||||
result["house_system"] = system
|
result["house_system"] = primary.system # FAKTYCZNIE użyty
|
||||||
|
result["house_system_requested"] = primary.requested
|
||||||
|
# Lista ostrzeżeń dla całego horoskopu — prezentacja, raport i PDF czytają
|
||||||
|
# jedno miejsce, więc żaden z nich nie może „zapomnieć" o fallbacku.
|
||||||
|
result["house_warnings"] = [primary.notice] if primary.notice else []
|
||||||
result["angles"] = {
|
result["angles"] = {
|
||||||
"Asc": _fmt("Asc", asc, off),
|
"Asc": _fmt("Asc", asc, off),
|
||||||
"MC": _fmt("MC", mc, off),
|
"MC": _fmt("MC", mc, off),
|
||||||
@@ -132,10 +148,15 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
if len(ordered) > 1:
|
if len(ordered) > 1:
|
||||||
result["house_systems"] = []
|
result["house_systems"] = []
|
||||||
for s in ordered:
|
for s in ordered:
|
||||||
cl = cusp_list if s == system else H.cusps(asc, mc, s)
|
cs = primary if s == system else H.cusps_detailed(ramc, eps, moment.lat, s)
|
||||||
result["house_systems"].append({"system": s, "cusps": _cusps_out(cl)})
|
block = {"system": s, "used_system": cs.system, "cusps": _cusps_out(cs.cusps)}
|
||||||
|
if cs.notice:
|
||||||
|
block["notice"] = cs.notice
|
||||||
|
if cs.notice not in result["house_warnings"]:
|
||||||
|
result["house_warnings"].append(cs.notice)
|
||||||
|
result["house_systems"].append(block)
|
||||||
for pdict, obj in zip(result["positions"], positions):
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cl)
|
pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cs.cusps)
|
||||||
|
|
||||||
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
|
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
|
||||||
from app.engine.firdaria import is_day_birth
|
from app.engine.firdaria import is_day_birth
|
||||||
|
|||||||
@@ -8,13 +8,31 @@ silnik dostarcza tylko RAMC i ε.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
||||||
|
|
||||||
WHOLE_SIGN = "whole_sign"
|
WHOLE_SIGN = "whole_sign"
|
||||||
EQUAL = "equal"
|
EQUAL = "equal"
|
||||||
|
EQUAL_MC = "equal_mc" # równe domy zakotwiczone na MC, nie na Asc
|
||||||
|
WHOLE_SIGN_ARIES = "whole_sign_aries" # znaki jako domy, ale dom I to ZAWSZE Baran
|
||||||
PORPHYRY = "porphyry"
|
PORPHYRY = "porphyry"
|
||||||
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
|
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania
|
||||||
|
# iteracyjnego i dochodzą osobno.
|
||||||
|
VEHLOW = "vehlow"
|
||||||
|
MORINUS = "morinus"
|
||||||
|
REGIOMONTANUS = "regiomontanus"
|
||||||
|
CAMPANUS = "campanus"
|
||||||
|
ALCABITUS = "alcabitus"
|
||||||
|
TOPOCENTRIC = "topocentric"
|
||||||
|
PLACIDUS = "placidus"
|
||||||
|
KOCH = "koch"
|
||||||
|
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris
|
||||||
|
# (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny:
|
||||||
|
# powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi.
|
||||||
|
SYSTEMS = (WHOLE_SIGN, WHOLE_SIGN_ARIES, EQUAL, EQUAL_MC, PORPHYRY, VEHLOW,
|
||||||
|
MORINUS, REGIOMONTANUS, CAMPANUS, ALCABITUS, TOPOCENTRIC,
|
||||||
|
PLACIDUS, KOCH)
|
||||||
|
|
||||||
|
|
||||||
def mean_obliquity(tt_jd: float) -> float:
|
def mean_obliquity(tt_jd: float) -> float:
|
||||||
@@ -31,12 +49,27 @@ def compute_mc(ramc_deg: float, eps_deg: float) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
|
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
|
||||||
|
"""Ascendent — punkt ekliptyki wschodzący na horyzoncie.
|
||||||
|
|
||||||
|
KOREKTA GAŁĘZI (błąd wykryty przez porównanie z wyrocznią, tests/oracle):
|
||||||
|
ekliptyka przecina horyzont w DWÓCH punktach — wschodzącym (Asc) i zachodzącym
|
||||||
|
(Dsc). `atan2` wybiera jeden z nich, ale powyżej koła podbiegunowego potrafi
|
||||||
|
wskazać ten NIEWŁAŚCIWY: dla szerokości 67°+ i szerokiego zakresu RAMC
|
||||||
|
zwracaliśmy Descendent, czyli Ascendent przesunięty o 180°. Skutek nie był
|
||||||
|
subtelny — planety lądowały w PRZECIWNYCH domach dla całej Skandynawii
|
||||||
|
północnej (Tromsø, Rovaniemi, Murmańsk).
|
||||||
|
|
||||||
|
Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze w półkolu
|
||||||
|
(0°, 180°) na wschód od MC. Reguła zweryfikowana na 46 800 przypadkach wobec
|
||||||
|
Swiss Ephemeris — zero rozbieżności.
|
||||||
|
"""
|
||||||
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
||||||
asc = math.atan2(
|
asc = norm360(math.degrees(math.atan2(
|
||||||
math.cos(r),
|
math.cos(r),
|
||||||
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
||||||
)
|
)))
|
||||||
return norm360(math.degrees(asc))
|
mc = compute_mc(ramc_deg, eps_deg)
|
||||||
|
return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else asc
|
||||||
|
|
||||||
|
|
||||||
def _trisect(a: float, b: float) -> tuple[float, float]:
|
def _trisect(a: float, b: float) -> tuple[float, float]:
|
||||||
@@ -45,10 +78,29 @@ def _trisect(a: float, b: float) -> tuple[float, float]:
|
|||||||
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
|
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
|
||||||
|
|
||||||
|
|
||||||
|
# Poniżej tej odległości od granicy znaku traktujemy Ascendent jak leżący DOKŁADNIE
|
||||||
|
# na niej. 1e-9° to 3,6 mikrosekundy łuku — o rzędy wielkości poniżej jakiejkolwiek
|
||||||
|
# realnej dokładności danych urodzeniowych, więc nie zmienia to żadnego horoskopu.
|
||||||
|
_SIGN_SNAP_DEG = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def _snap_to_sign_boundary(lon: float) -> float:
|
||||||
|
"""Przyciąga długość do granicy znaku, gdy jest od niej o włos.
|
||||||
|
|
||||||
|
Whole sign jest NIECIĄGŁY na granicach znaków: różnica 10⁻¹¹° w Ascendencie
|
||||||
|
przerzuca cały dom I o 30°. Bez tego przyciągania ten sam horoskop policzony
|
||||||
|
na innej maszynie mógłby dać inny wynik (wykryte przez porównanie z wyrocznią:
|
||||||
|
nasz Asc = 359,999999999976, swissepha = 1e-10 — ta sama wartość po dwóch
|
||||||
|
stronach granicy Ryby/Baran). Determinizm jest tu ważniejszy niż dosłowność
|
||||||
|
zmiennoprzecinkowa."""
|
||||||
|
nearest = round(lon / 30.0) * 30.0
|
||||||
|
return norm360(nearest) if abs(lon - nearest) < _SIGN_SNAP_DEG else lon
|
||||||
|
|
||||||
|
|
||||||
def cusps(asc: float, mc: float, system: str) -> list[float]:
|
def cusps(asc: float, mc: float, system: str) -> list[float]:
|
||||||
"""Zwraca 12 cusps (długości) domów 1..12."""
|
"""Zwraca 12 cusps (długości) domów 1..12."""
|
||||||
if system == WHOLE_SIGN:
|
if system == WHOLE_SIGN:
|
||||||
start = sign_index(asc) * 30.0
|
start = sign_index(_snap_to_sign_boundary(asc)) * 30.0
|
||||||
return [norm360(start + 30.0 * i) for i in range(12)]
|
return [norm360(start + 30.0 * i) for i in range(12)]
|
||||||
if system == EQUAL:
|
if system == EQUAL:
|
||||||
return [norm360(asc + 30.0 * i) for i in range(12)]
|
return [norm360(asc + 30.0 * i) for i in range(12)]
|
||||||
@@ -64,14 +116,472 @@ def cusps(asc: float, mc: float, system: str) -> list[float]:
|
|||||||
raise ValueError(f"nieznany system domów: {system}")
|
raise ValueError(f"nieznany system domów: {system}")
|
||||||
|
|
||||||
|
|
||||||
|
def polar_circle(eps_deg: float) -> float:
|
||||||
|
"""Szerokość koła podbiegunowego [°] dla danego nachylenia ekliptyki.
|
||||||
|
|
||||||
|
NIE jest to stała 66,56°: ε zmienia się z datą (ok. 23,71° w 370 p.n.e.,
|
||||||
|
23,44° dziś), więc granica przesuwa się o ~0,3° w zakresie dat programu.
|
||||||
|
Powyżej niej stopnie ekliptyki bywają okołobiegunowe — nie wschodzą ani nie
|
||||||
|
zachodzą — przez co systemy oparte na łuku dobowym (Placidus, Koch) tracą
|
||||||
|
definicję."""
|
||||||
|
return 90.0 - abs(eps_deg)
|
||||||
|
|
||||||
|
|
||||||
|
# ── geometria wektorowa dla systemów dzielących koła wielkie ─────────────
|
||||||
|
# Wzory na te systemy krążą w literaturze w kilku wariantach i łatwo o pomyłkę
|
||||||
|
# w gałęzi albo znaku. Liczymy więc WPROST z geometrii: budujemy wektory kierunkowe
|
||||||
|
# w układzie równikowym, przecinamy płaszczyzny i dopiero wynik zamieniamy na
|
||||||
|
# długość ekliptyczną. Jest to dłuższe, ale jednoznaczne i sprawdzalne.
|
||||||
|
|
||||||
|
def _cross(a, b):
|
||||||
|
return (a[1] * b[2] - a[2] * b[1],
|
||||||
|
a[2] * b[0] - a[0] * b[2],
|
||||||
|
a[0] * b[1] - a[1] * b[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _dot(a, b):
|
||||||
|
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _equatorial_to_lon(v, eps_rad: float) -> float:
|
||||||
|
"""Wektor w układzie RÓWNIKOWYM → długość ekliptyczna [°]."""
|
||||||
|
x, y, z = v
|
||||||
|
y_ecl = y * math.cos(eps_rad) + z * math.sin(eps_rad)
|
||||||
|
return norm360(math.degrees(math.atan2(y_ecl, x)))
|
||||||
|
|
||||||
|
|
||||||
|
def _ecliptic_pole(eps_rad: float):
|
||||||
|
"""Biegun ekliptyki (normalna płaszczyzny ekliptyki) w układzie równikowym."""
|
||||||
|
return (0.0, -math.sin(eps_rad), math.cos(eps_rad))
|
||||||
|
|
||||||
|
|
||||||
|
def _horizon_north(ramc_rad: float, lat_rad: float):
|
||||||
|
"""Punkt północny horyzontu: RA = RAMC+180°, deklinacja = 90°−φ."""
|
||||||
|
return (-math.sin(lat_rad) * math.cos(ramc_rad),
|
||||||
|
-math.sin(lat_rad) * math.sin(ramc_rad),
|
||||||
|
math.cos(lat_rad))
|
||||||
|
|
||||||
|
|
||||||
|
# Domy POŚREDNIE (bez osi) i to, po której stronie MC leżą. Domy 11, 12, 2, 3
|
||||||
|
# są na wschód od MC (przesunięcie 0–180°), domy 5, 6, 8, 9 — na zachód.
|
||||||
|
_INTERMEDIATE = {1: True, 2: True, 4: False, 5: False,
|
||||||
|
7: False, 8: False, 10: True, 11: True}
|
||||||
|
|
||||||
|
|
||||||
|
def _house_circle_cusp(north, q, eps_rad: float, mc: float, east_of_mc: bool) -> float:
|
||||||
|
"""Cusp = przecięcie ekliptyki z kołem domu.
|
||||||
|
|
||||||
|
Koło domu przechodzi przez punkty N/S horyzontu oraz przez punkt podziału `q`
|
||||||
|
(na równiku dla Regiomontanusa, na pierwszym wertykale dla Campanusa).
|
||||||
|
Przecięcie dwóch płaszczyzn daje PROSTĄ, czyli DWA antypodyczne kierunki;
|
||||||
|
wybieramy ten po właściwej stronie południka.
|
||||||
|
|
||||||
|
Używane WYŁĄCZNIE dla domów pośrednich. Osie (1, 4, 7, 10) znamy dokładnie
|
||||||
|
z Asc i MC — liczenie ich tą drogą było błędem, bo leżą dokładnie na granicy
|
||||||
|
„wschód/zachód" (przesunięcie 0° i 180°), gdzie porównanie zmiennoprzecinkowe
|
||||||
|
się chwieje i potrafi wybrać przeciwny punkt nieba."""
|
||||||
|
normal = _cross(north, q) # normalna płaszczyzny koła domu
|
||||||
|
line = _cross(normal, _ecliptic_pole(eps_rad))
|
||||||
|
lon = _equatorial_to_lon(line, eps_rad)
|
||||||
|
return lon if ((lon - mc) % 360.0 < 180.0) == east_of_mc else norm360(lon + 180.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _culminating_mc(mc: float, eps: float, lat: float) -> float:
|
||||||
|
"""Punkt południka, który dla tej szerokości leży NAD horyzontem.
|
||||||
|
|
||||||
|
Systemy oparte na horyzoncie (Regiomontanus, Campanus, Topocentric) biorą jako
|
||||||
|
dziesiąty dom punkt GÓRUJĄCY, a nie matematyczne MC — a za kołem podbiegunowym
|
||||||
|
to nie zawsze to samo. Punkt południka o deklinacji δ ma wysokość 90−|φ−δ|,
|
||||||
|
więc jest nad horyzontem dokładnie wtedy, gdy |φ−δ| < 90.
|
||||||
|
|
||||||
|
Systemy dzielące samą ekliptykę (porphyry, equal, alcabitus, whole sign) tego
|
||||||
|
nie robią — i tak samo zachowuje się wyrocznia."""
|
||||||
|
dec = math.degrees(math.asin(math.sin(math.radians(mc)) * math.sin(math.radians(eps))))
|
||||||
|
return norm360(mc + 180.0) if abs(lat - dec) > 90.0 else mc
|
||||||
|
|
||||||
|
|
||||||
|
def _with_exact_angles(intermediate, asc: float, mc: float) -> list[float]:
|
||||||
|
"""Składa 12 cuspów: osie wstawione dokładnie, reszta z geometrii."""
|
||||||
|
out = [0.0] * 12
|
||||||
|
out[0], out[3] = asc, norm360(mc + 180.0) # Asc, IC
|
||||||
|
out[6], out[9] = norm360(asc + 180.0), mc # Dsc, MC
|
||||||
|
for i, value in intermediate.items():
|
||||||
|
out[i] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ra_to_ecliptic_lon(ra_deg: float, eps_rad: float) -> float:
|
||||||
|
"""Punkt ekliptyki o zadanej rektascensji (koło godzinne → ekliptyka)."""
|
||||||
|
r = math.radians(ra_deg)
|
||||||
|
return norm360(math.degrees(math.atan2(math.sin(r), math.cos(r) * math.cos(eps_rad))))
|
||||||
|
|
||||||
|
|
||||||
|
def _equator_point(ra_deg: float):
|
||||||
|
"""Kierunek punktu na równiku niebieskim o danej rektascensji."""
|
||||||
|
r = math.radians(ra_deg)
|
||||||
|
return (math.cos(r), math.sin(r), 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _prime_vertical_point(ramc_rad: float, lat_rad: float, angle_deg: float):
|
||||||
|
"""Punkt pierwszego wertykału, `angle_deg` od punktu wschodu w stronę nadiru.
|
||||||
|
|
||||||
|
Pierwszy wertykał to koło przez wschód, zenit, zachód i nadir — Campanus dzieli
|
||||||
|
właśnie je."""
|
||||||
|
east = (-math.sin(ramc_rad), math.cos(ramc_rad), 0.0)
|
||||||
|
zenith = (math.cos(lat_rad) * math.cos(ramc_rad),
|
||||||
|
math.cos(lat_rad) * math.sin(ramc_rad),
|
||||||
|
math.sin(lat_rad))
|
||||||
|
a = math.radians(angle_deg)
|
||||||
|
return tuple(east[i] * math.cos(a) - zenith[i] * math.sin(a) for i in range(3))
|
||||||
|
|
||||||
|
|
||||||
|
def _cusps_regiomontanus(ramc: float, eps: float, lat: float,
|
||||||
|
asc: float, mc: float) -> list[float]:
|
||||||
|
"""Równik niebieski dzielony na 12 równych łuków, rzut kołami przez N/S horyzontu."""
|
||||||
|
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
||||||
|
north = _horizon_north(rr, lr)
|
||||||
|
mid = {i: _house_circle_cusp(north, _equator_point(ramc + 90.0 + 30.0 * i), er, mc, e)
|
||||||
|
for i, e in _INTERMEDIATE.items()}
|
||||||
|
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
||||||
|
|
||||||
|
|
||||||
|
def _cusps_campanus(ramc: float, eps: float, lat: float,
|
||||||
|
asc: float, mc: float) -> list[float]:
|
||||||
|
"""Pierwszy wertykał dzielony na 12 równych łuków, rzut tak samo jak wyżej."""
|
||||||
|
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
||||||
|
north = _horizon_north(rr, lr)
|
||||||
|
mid = {i: _house_circle_cusp(north, _prime_vertical_point(rr, lr, 30.0 * i), er, mc, e)
|
||||||
|
for i, e in _INTERMEDIATE.items()}
|
||||||
|
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
||||||
|
|
||||||
|
|
||||||
|
def _cusps_morinus(ramc: float, eps: float) -> list[float]:
|
||||||
|
"""Równik dzielony od RAMC i rzutowany WPROST na ekliptykę — bez horyzontu.
|
||||||
|
|
||||||
|
Dlatego Morinus jako jedyny nie zależy od szerokości geograficznej, a jego
|
||||||
|
dom I nie pokrywa się z Ascendentem. Uwaga: to ZAMIANA WSPÓŁRZĘDNYCH punktu
|
||||||
|
równika (RA, dec=0) na ekliptyczne, a nie rzut kołem godzinnym — te dwie
|
||||||
|
operacje dają różne wyniki i pomylenie ich kosztowało tu do 5°."""
|
||||||
|
er = math.radians(eps)
|
||||||
|
return [_equatorial_to_lon(_equator_point(ramc + 90.0 + 30.0 * i), er)
|
||||||
|
for i in range(12)]
|
||||||
|
|
||||||
|
|
||||||
|
def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
|
||||||
|
"""Łuki równika MC→Asc i Asc→IC dzielone na trzy; rzut kołami godzinnymi."""
|
||||||
|
er = math.radians(eps)
|
||||||
|
a = math.radians(asc)
|
||||||
|
ra_asc = norm360(math.degrees(math.atan2(math.sin(a) * math.cos(er), math.cos(a))))
|
||||||
|
day = (ra_asc - ramc) % 360.0 # łuk MC → Asc (domy 11, 12)
|
||||||
|
night = (ramc + 180.0 - ra_asc) % 360.0 # łuk Asc → IC (domy 2, 3)
|
||||||
|
|
||||||
|
ra = [0.0] * 12
|
||||||
|
ra[9] = ramc # dom 10 = MC
|
||||||
|
ra[10] = ramc + day / 3.0 # dom 11
|
||||||
|
ra[11] = ramc + 2.0 * day / 3.0 # dom 12
|
||||||
|
ra[0] = ra_asc # dom 1 = Asc
|
||||||
|
ra[1] = ra_asc + night / 3.0 # dom 2
|
||||||
|
ra[2] = ra_asc + 2.0 * night / 3.0 # dom 3
|
||||||
|
for i in range(6): # domy 4–9 naprzeciw 10–3
|
||||||
|
ra[i + 3] = ra[(i + 9) % 12] + 180.0
|
||||||
|
return [_ra_to_ecliptic_lon(x, er) for x in ra]
|
||||||
|
|
||||||
|
|
||||||
|
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (Polich–Page).
|
||||||
|
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
|
||||||
|
# Polich–Page: dom pośredni to Ascendent policzony pod własnym „biegunem"
|
||||||
|
# tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu. Rodzina jest CIĄGŁA:
|
||||||
|
# przy k=0 (biegun 0, przesunięcie −90°) daje MC, przy k=3 (biegun φ, przesunięcie 0)
|
||||||
|
# Ascendent, a domy 11 i 12 leżą po drodze.
|
||||||
|
#
|
||||||
|
# Cała trudność tego systemu siedziała w wyborze gałęzi — dwa koła wielkie
|
||||||
|
# przecinają się w dwóch punktach antypodycznych. Heurystyki („po której stronie
|
||||||
|
# MC", „w łuku kwadrantu", „wschodnia połowa horyzontu", śledzenie ciągłości
|
||||||
|
# krokami) myliły się na 6–11% przypadków powyżej ~70°, bo każda z nich rozstrzyga
|
||||||
|
# LOKALNIE, a przy dużych szerokościach kolejność domów potrafi się odwrócić.
|
||||||
|
#
|
||||||
|
# Rozwiązanie: nie wybierać w ogóle. Iloczyn wektorowy zenitu z biegunem ekliptyki
|
||||||
|
# jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot — dwuznaczność
|
||||||
|
# wprowadza dopiero atan2. Zostajemy więc w wektorach, a znak ustalamy RAZ, kotwicząc
|
||||||
|
# rodzinę na MC górującym. Stąd zgodność co do zera na całej dziedzinie, bez iteracji
|
||||||
|
# i bez zawężania szerokości.
|
||||||
|
|
||||||
|
# Dom → (przesunięcie RAMC [°], ułamek bieguna k/3).
|
||||||
|
_TOPO_STEP = {10: (-60.0, 1 / 3), 11: (-30.0, 2 / 3), # domy 11, 12
|
||||||
|
1: (30.0, 2 / 3), 2: (60.0, 1 / 3)} # domy 2, 3
|
||||||
|
|
||||||
|
|
||||||
|
def _cusps_topocentric(ramc: float, eps: float, lat: float,
|
||||||
|
asc: float, mc: float) -> list[float]:
|
||||||
|
"""Ascendenty pod biegunami tan(P) = tan(φ)·k/3, liczone wektorowo.
|
||||||
|
|
||||||
|
Domy 5, 6, 8, 9 bierzemy jako OPOZYCJE domów 11, 12, 2, 3 — to nie skrót,
|
||||||
|
lecz własność konstrukcji: przeciwległe domy leżą na tym samym kole wielkim.
|
||||||
|
|
||||||
|
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik
|
||||||
|
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
|
||||||
|
konstrukcji „ascendent pod biegunem"."""
|
||||||
|
# Cuspy topocentrica są poprawne na CAŁEJ dziedzinie (zgodne z wyrocznią co do
|
||||||
|
# zera), ale powyżej koła podbiegunowego przestają DZIELIĆ OKRĄG: domy nachodzą
|
||||||
|
# na siebie, bo cusp VII (= I + 180°) wypada przed cuspem VI. Przypisanie planety
|
||||||
|
# do domu traci wtedy sens — co potwierdza sama wyrocznia, której swe_house_pos
|
||||||
|
# przeczy tam własnym cuspom (100% zgodności do 62°, 83,9% przy 66°, ok. 50%
|
||||||
|
# przy 72°). Odmawiamy, zamiast zwracać liczbę bez znaczenia.
|
||||||
|
#
|
||||||
|
# To INNY rodzaj granicy niż u Placidusa i Kocha: tam nie istnieją same cuspy,
|
||||||
|
# tu istnieją, tylko nie tworzą podziału. Próg jest wyprowadzony z warunku
|
||||||
|
# „dwanaście cuspów sumuje się do 360°", nie dobrany pod wynik testu — i wypada
|
||||||
|
# na kole podbiegunowym (zmierzone: 100% podziałów do 65°, 78% w pasie 66-67°).
|
||||||
|
if abs(lat) >= polar_circle(eps):
|
||||||
|
raise HouseSystemUndefined(
|
||||||
|
f"φ={lat:.4f}° powyżej koła podbiegunowego ({polar_circle(eps):.4f}° dla "
|
||||||
|
f"ε={eps:.4f}°): cuspy topocentryczne przestają dzielić okrąg, "
|
||||||
|
f"domy nachodzą na siebie")
|
||||||
|
er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
|
||||||
|
epole = _ecliptic_pole(er)
|
||||||
|
# Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
|
||||||
|
# cała rodzina obraca się razem z dziesiątym domem — stąd zwrot iloczynu.
|
||||||
|
culminating = _culminating_mc(mc, eps, lat)
|
||||||
|
sign = 1.0 if abs(((culminating - mc + 180.0) % 360.0) - 180.0) > 90.0 else -1.0
|
||||||
|
|
||||||
|
out = [0.0] * 12
|
||||||
|
for i, (offset, fraction) in _TOPO_STEP.items():
|
||||||
|
th = math.radians(ramc + offset)
|
||||||
|
pole = math.atan(tan_lat * fraction)
|
||||||
|
zenith = (math.cos(pole) * math.cos(th),
|
||||||
|
math.cos(pole) * math.sin(th),
|
||||||
|
math.sin(pole))
|
||||||
|
v = _cross(zenith, epole)
|
||||||
|
lon = _equatorial_to_lon(tuple(sign * x for x in v), er)
|
||||||
|
out[i] = lon
|
||||||
|
out[(i + 6) % 12] = norm360(lon + 180.0)
|
||||||
|
out[0], out[3] = asc, norm360(culminating + 180.0)
|
||||||
|
out[6], out[9] = norm360(asc + 180.0), culminating
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── systemy łuku dobowego (Placidus, Koch) ───────────────────────────────
|
||||||
|
# Różnią się od wszystkich poprzednich tym, że NIE MAJĄ wzoru zamkniętego: cusp
|
||||||
|
# jest zdefiniowany warunkiem na samego siebie („punkt, który przebył 1/3 swojego
|
||||||
|
# półłuku"), więc trzeba go znaleźć iteracyjnie. Mają też jako jedyne REALNĄ
|
||||||
|
# granicę dziedziny — powyżej koła podbiegunowego stopnie ekliptyki bywają
|
||||||
|
# okołobiegunowe, nie wschodzą ani nie zachodzą, i półłuk po prostu nie istnieje.
|
||||||
|
|
||||||
|
|
||||||
|
class HouseSystemUndefined(ValueError):
|
||||||
|
"""System domów nie ma definicji dla podanych parametrów (nie: błąd liczenia).
|
||||||
|
|
||||||
|
Podnoszone zamiast zwrócenia liczby, bo cicha podmiana systemu jest gorsza
|
||||||
|
niż błąd: wykres wygląda poprawnie, a planety siedzą w innych domach, niż
|
||||||
|
astrolog zamawiał. Warstwa aplikacyjna łapie to w cusps_detailed() i robi
|
||||||
|
JAWNY fallback."""
|
||||||
|
|
||||||
|
|
||||||
|
_ITER_MAX = 100
|
||||||
|
_ITER_TOL_DEG = 1e-11
|
||||||
|
|
||||||
|
|
||||||
|
def _declination_of_ecliptic_lon(lon_deg: float, eps_rad: float) -> float:
|
||||||
|
"""Deklinacja punktu LEŻĄCEGO NA EKLIPTYCE o danej długości."""
|
||||||
|
return math.degrees(math.asin(math.sin(eps_rad) * math.sin(math.radians(lon_deg))))
|
||||||
|
|
||||||
|
|
||||||
|
def _ascensional_difference(dec_deg: float, lat_deg: float) -> float:
|
||||||
|
"""Różnica wschodnia: o ile półłuk dobowy odbiega od 90°.
|
||||||
|
|
||||||
|
sin(AD) = tan(φ)·tan(δ). Gdy |tan(φ)·tan(δ)| ≥ 1, punkt jest okołobiegunowy
|
||||||
|
(nigdy nie wschodzi albo nigdy nie zachodzi) i półłuk nie istnieje."""
|
||||||
|
v = math.tan(math.radians(lat_deg)) * math.tan(math.radians(dec_deg))
|
||||||
|
if abs(v) >= 1.0:
|
||||||
|
raise HouseSystemUndefined(
|
||||||
|
f"punkt okołobiegunowy (tan φ·tan δ = {v:.6f}): półłuk dobowy nie istnieje")
|
||||||
|
return math.degrees(math.asin(v))
|
||||||
|
|
||||||
|
|
||||||
|
# Dom → (ułamek półłuku, czy łuk NOCNY). Domy 11 i 12 dzielą łuk dzienny licząc
|
||||||
|
# od MC; domy 2 i 3 — łuk nocny, licząc WSTECZ od IC.
|
||||||
|
_PLACIDUS_STEP = {10: (1 / 3, False), 11: (2 / 3, False),
|
||||||
|
1: (2 / 3, True), 2: (1 / 3, True)}
|
||||||
|
|
||||||
|
|
||||||
|
def _placidus_cusp(ramc: float, eps: float, lat: float,
|
||||||
|
fraction: float, nocturnal: bool) -> float:
|
||||||
|
"""Punkt ekliptyki, który przebył `fraction` swojego półłuku.
|
||||||
|
|
||||||
|
Warunek jest uwikłany: półłuk zależy od deklinacji, deklinacja od długości,
|
||||||
|
a długość od położenia — więc iterujemy po punkcie stałym. Zbieżność jest
|
||||||
|
szybka z dala od koła podbiegunowego i psuje się przy nim, dlatego brak
|
||||||
|
zbieżności traktujemy jako wyjście poza dziedzinę, a nie jako wynik."""
|
||||||
|
eps_rad = math.radians(eps)
|
||||||
|
# Start od podziału równomiernego — to Porphyry na równiku, czyli dokładnie
|
||||||
|
# ten przypadek, w którym Placidus się do niego sprowadza.
|
||||||
|
ra = ramc + 180.0 - 90.0 * fraction if nocturnal else ramc + 90.0 * fraction
|
||||||
|
for _ in range(_ITER_MAX):
|
||||||
|
dec = _declination_of_ecliptic_lon(_ra_to_ecliptic_lon(ra, eps_rad), eps_rad)
|
||||||
|
ad = _ascensional_difference(dec, lat)
|
||||||
|
nxt = (ramc + 180.0 - fraction * (90.0 - ad) if nocturnal
|
||||||
|
else ramc + fraction * (90.0 + ad))
|
||||||
|
if abs(nxt - ra) < _ITER_TOL_DEG:
|
||||||
|
return _ra_to_ecliptic_lon(nxt, eps_rad)
|
||||||
|
ra = nxt
|
||||||
|
raise HouseSystemUndefined(
|
||||||
|
f"brak zbieżności po {_ITER_MAX} krokach (φ={lat:.4f}, RAMC={ramc:.4f})")
|
||||||
|
|
||||||
|
|
||||||
|
def _cusps_placidus(ramc: float, eps: float, lat: float,
|
||||||
|
asc: float, mc: float) -> list[float]:
|
||||||
|
"""Półłuki dobowe i nocne dzielone na trzy — każdy punkt swoim własnym łukiem."""
|
||||||
|
if abs(lat) >= polar_circle(eps):
|
||||||
|
raise HouseSystemUndefined(
|
||||||
|
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
|
||||||
|
inter = {i: _placidus_cusp(ramc, eps, lat, f, noct)
|
||||||
|
for i, (f, noct) in _PLACIDUS_STEP.items()}
|
||||||
|
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
|
||||||
|
return _with_exact_angles(inter, asc, mc)
|
||||||
|
|
||||||
|
|
||||||
|
# Koch dzieli CZAS, nie łuk na niebie. Kryterium: ile czasu minęło od wschodu
|
||||||
|
# tego stopnia zodiaku, który stoi na MC. Ten odcinek (półłuk dobowy stopnia MC)
|
||||||
|
# dzielimy na trzy i dla punktów podziału liczymy ZWYKŁY Ascendent — stąd nazwa
|
||||||
|
# „system miejsca urodzenia". Zgodne z definicją Astrodienst (astro.com/astrowiki).
|
||||||
|
#
|
||||||
|
# W przeciwieństwie do Placidusa NIE wymaga iteracji: półłuk zależy od deklinacji
|
||||||
|
# stopnia MC, którą znamy wprost. Granicę dziedziny dzieli natomiast z Placidusem —
|
||||||
|
# gdy stopień MC jest okołobiegunowy, „moment jego wschodu" nie istnieje.
|
||||||
|
_KOCH_OFFSET = {10: -2 / 3, 11: -1 / 3, 1: 1 / 3, 2: 2 / 3}
|
||||||
|
|
||||||
|
|
||||||
|
def _cusps_koch(ramc: float, eps: float, lat: float,
|
||||||
|
asc: float, mc: float) -> list[float]:
|
||||||
|
"""Ascendenty dla chwil trójdzielących drogę stopnia MC od wschodu do górowania."""
|
||||||
|
if abs(lat) >= polar_circle(eps):
|
||||||
|
raise HouseSystemUndefined(
|
||||||
|
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
|
||||||
|
dec_mc = _declination_of_ecliptic_lon(mc, math.radians(eps))
|
||||||
|
half_arc = 90.0 + _ascensional_difference(dec_mc, lat)
|
||||||
|
inter = {i: compute_asc(ramc + f * half_arc, eps, lat)
|
||||||
|
for i, f in _KOCH_OFFSET.items()}
|
||||||
|
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
|
||||||
|
return _with_exact_angles(inter, asc, mc)
|
||||||
|
|
||||||
|
|
||||||
|
def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]:
|
||||||
|
"""Kanoniczne wejście: (RAMC, ε, φ) → 12 cusps.
|
||||||
|
|
||||||
|
Systemy proste (whole sign / equal / porphyry) potrzebują tylko Asc i MC,
|
||||||
|
ale systemy egzotyczne dzielą inne koła wielkie i wymagają pełnego zestawu
|
||||||
|
(RAMC, ε, φ). Ta funkcja jest wspólnym punktem wejścia dla obu rodzajów —
|
||||||
|
i to ją porównuje z wyrocznią framework testowy (tests/oracle).
|
||||||
|
"""
|
||||||
|
asc = compute_asc(ramc, eps, lat)
|
||||||
|
mc = compute_mc(ramc, eps)
|
||||||
|
if system in (WHOLE_SIGN, EQUAL, PORPHYRY):
|
||||||
|
return cusps(asc, mc, system)
|
||||||
|
if system == WHOLE_SIGN_ARIES:
|
||||||
|
# Znaki jako domy, ale numeracja rusza od Barana niezależnie od Ascendentu.
|
||||||
|
# Wariant spotykany w tradycji indyjskiej i w części szkół hellenistycznych.
|
||||||
|
return [norm360(30.0 * i) for i in range(12)]
|
||||||
|
if system == EQUAL_MC:
|
||||||
|
# Równe domy jak `equal`, ale zakotwiczone na MC: dom X zaczyna się
|
||||||
|
# DOKŁADNIE na MC, więc oś południka wypada na granicy domu, a nie w środku.
|
||||||
|
return [norm360(mc + 90.0 + 30.0 * i) for i in range(12)]
|
||||||
|
if system == VEHLOW:
|
||||||
|
# equal, ale Ascendent leży w ŚRODKU domu I, nie na jego początku
|
||||||
|
return [norm360(asc - 15.0 + 30.0 * i) for i in range(12)]
|
||||||
|
if system == MORINUS:
|
||||||
|
return _cusps_morinus(ramc, eps)
|
||||||
|
if system == REGIOMONTANUS:
|
||||||
|
return _cusps_regiomontanus(ramc, eps, lat, asc, mc)
|
||||||
|
if system == CAMPANUS:
|
||||||
|
return _cusps_campanus(ramc, eps, lat, asc, mc)
|
||||||
|
if system == ALCABITUS:
|
||||||
|
return _cusps_alcabitus(ramc, eps, asc)
|
||||||
|
if system == TOPOCENTRIC:
|
||||||
|
return _cusps_topocentric(ramc, eps, lat, asc, mc)
|
||||||
|
if system == PLACIDUS:
|
||||||
|
return _cusps_placidus(ramc, eps, lat, asc, mc)
|
||||||
|
if system == KOCH:
|
||||||
|
return _cusps_koch(ramc, eps, lat, asc, mc)
|
||||||
|
raise ValueError(f"nieznany system domów: {system}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── jawny fallback poza dziedziną ────────────────────────────────────────
|
||||||
|
# Placidus i Koch jako jedyne mają miejsca, w których po prostu NIE ISTNIEJĄ.
|
||||||
|
# Astrolog z Tromsø ma dostać wynik, ale musi wiedzieć, że dostał inny system —
|
||||||
|
# cicha podmiana jest gorsza niż brak wyniku, bo jest nie do wykrycia z wykresu.
|
||||||
|
|
||||||
|
FALLBACK_SYSTEM = PORPHYRY
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CuspSet:
|
||||||
|
"""12 cuspów + uczciwa informacja, czym naprawdę zostały policzone."""
|
||||||
|
|
||||||
|
cusps: list[float]
|
||||||
|
system: str # system FAKTYCZNIE użyty
|
||||||
|
requested: str # o który poproszono
|
||||||
|
reason: str | None = None # dlaczego nie dało się użyć żądanego
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_fallback(self) -> bool:
|
||||||
|
return self.system != self.requested
|
||||||
|
|
||||||
|
@property
|
||||||
|
def notice(self) -> str | None:
|
||||||
|
"""Komunikat dla człowieka. Ma trafić na ekran, do raportu i do PDF-a."""
|
||||||
|
if not self.is_fallback:
|
||||||
|
return None
|
||||||
|
return (f"UWAGA: system domów \u201e{self.requested}\u201d nie ma definicji "
|
||||||
|
f"dla tego miejsca i czasu \u2014 {self.reason}. Domy policzono "
|
||||||
|
f"systemem \u201e{self.system}\u201d. To NIE jest ten sam podzia\u0142: "
|
||||||
|
f"pozycje planet s\u0105 poprawne, ale przypisanie ich do dom\u00f3w "
|
||||||
|
f"pochodzi z innego systemu.")
|
||||||
|
|
||||||
|
|
||||||
|
def cusps_detailed(ramc: float, eps: float, lat: float, system: str) -> CuspSet:
|
||||||
|
"""Jak cusps_for, ale zamiast wyjątku poza dziedziną robi JAWNY fallback.
|
||||||
|
|
||||||
|
cusps_for zostaje funkcją czystą i nieustępliwą (to ją porównuje wyrocznia);
|
||||||
|
ustępstwo wobec rzeczywistości jest tutaj — i zawsze zostawia ślad."""
|
||||||
|
try:
|
||||||
|
return CuspSet(cusps_for(ramc, eps, lat, system), system, system)
|
||||||
|
except HouseSystemUndefined as e:
|
||||||
|
return CuspSet(cusps_for(ramc, eps, lat, FALLBACK_SYSTEM),
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -108,6 +108,19 @@ class SynastryRequest(BaseModel):
|
|||||||
aspect_minor: bool = False
|
aspect_minor: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/bases")
|
||||||
|
def bases() -> dict:
|
||||||
|
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||||
|
|
||||||
|
Przelot do warstwy danych — same METADANE, żadnej treści baz."""
|
||||||
|
from app.clients.data_client import DataClient
|
||||||
|
|
||||||
|
try:
|
||||||
|
return DataClient().bases()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/chart/synastry")
|
@app.post("/chart/synastry")
|
||||||
def chart_synastry(req: SynastryRequest) -> dict:
|
def chart_synastry(req: SynastryRequest) -> dict:
|
||||||
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
|
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
|
||||||
|
|||||||
@@ -200,6 +200,11 @@ def _chart_section(chart: dict, moment_label: str | None) -> str:
|
|||||||
if meta:
|
if meta:
|
||||||
lines.append(" · ".join(meta))
|
lines.append(" · ".join(meta))
|
||||||
|
|
||||||
|
# Model interpretuje domy, więc musi wiedzieć, że podział pochodzi z innego
|
||||||
|
# systemu, niż zamówiono — inaczej napisze „Twój Placidus" o Porphyrym.
|
||||||
|
for w in chart.get("house_warnings") or []:
|
||||||
|
lines.append(f"\n{w}")
|
||||||
|
|
||||||
if chart.get("positions"):
|
if chart.get("positions"):
|
||||||
lines.append("\n## Pozycje")
|
lines.append("\n## Pozycje")
|
||||||
for p in chart["positions"]:
|
for p in chart["positions"]:
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
Waliduje względem astro.com dla horoskopu referencyjnego.
|
Waliduje względem astro.com dla horoskopu referencyjnego.
|
||||||
"""
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine import houses as H
|
||||||
from app.engine.chart import build_chart
|
from app.engine.chart import build_chart
|
||||||
|
|
||||||
|
|
||||||
@@ -61,8 +64,31 @@ def test_primary_always_first_even_if_not_listed(own_engine, reference_moment):
|
|||||||
|
|
||||||
|
|
||||||
def test_unknown_extra_system_is_ignored(own_engine, reference_moment):
|
def test_unknown_extra_system_is_ignored(own_engine, reference_moment):
|
||||||
"""Nieobsługiwany system (np. placidus — dojdzie przez swisseph osobno) jest
|
"""Nieznana nazwa systemu jest po prostu pomijana, nie wywala horoskopu.
|
||||||
po prostu pomijany, nie wywala horoskopu."""
|
|
||||||
|
(Placidus był tu kiedyś przykładem systemu nieobsługiwanego — od Etapu 2
|
||||||
|
jest liczony, więc przykładem musi być nazwa, której naprawdę nie znamy.)"""
|
||||||
chart = build_chart(own_engine, reference_moment, "whole_sign",
|
chart = build_chart(own_engine, reference_moment, "whole_sign",
|
||||||
house_systems=["placidus", "equal"])
|
house_systems=["nie-ma-takiego-systemu", "equal"])
|
||||||
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal"]
|
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("system", H.SYSTEMS)
|
||||||
|
def test_every_advertised_system_builds_a_chart(own_engine, reference_moment, system):
|
||||||
|
"""Każdy system z houses.SYSTEMS musi przejść przez build_chart. Pilnuje,
|
||||||
|
żeby rozszerzenie SYSTEMS bez przepięcia chart.py nie dało 500 przy wyborze
|
||||||
|
systemu, który jest na liście, ale nie w użytej funkcji."""
|
||||||
|
chart = build_chart(own_engine, reference_moment, system)
|
||||||
|
assert chart["house_system"] == system
|
||||||
|
assert len(chart["cusps"]) == 12
|
||||||
|
assert all(1 <= p["house"] <= 12 for p in chart["positions"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_systems_side_by_side_cover_all(own_engine, reference_moment):
|
||||||
|
chart = build_chart(own_engine, reference_moment, H.WHOLE_SIGN,
|
||||||
|
house_systems=list(H.SYSTEMS))
|
||||||
|
blocks = chart["house_systems"]
|
||||||
|
assert blocks[0]["system"] == H.WHOLE_SIGN # prymarny zawsze pierwszy
|
||||||
|
assert {b["system"] for b in blocks} == set(H.SYSTEMS)
|
||||||
|
for p in chart["positions"]:
|
||||||
|
assert set(p["houses"]) == set(H.SYSTEMS)
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
|
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.engine import houses as H
|
from app.engine import houses as H
|
||||||
|
|
||||||
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
||||||
@@ -39,3 +43,306 @@ def test_assign_house_whole_sign():
|
|||||||
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°)
|
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°)
|
||||||
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
||||||
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
|
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
|
||||||
|
|
||||||
|
|
||||||
|
# ── regresje wykryte przez porównanie z wyrocznią (tests/oracle) ──────────
|
||||||
|
|
||||||
|
def test_ascendant_branch_beyond_polar_circle():
|
||||||
|
"""REGRESJA: powyżej koła podbiegunowego `atan2` wybierał NIEWŁAŚCIWY punkt
|
||||||
|
przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent zamiast Ascendenta,
|
||||||
|
czyli błąd o 180°. Planety lądowały w PRZECIWNYCH domach dla całej północnej
|
||||||
|
Skandynawii. Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze
|
||||||
|
w półkolu (0°,180°) na wschód od MC."""
|
||||||
|
eps = 23.4393
|
||||||
|
for lat in (67.0, 70.0, 85.0, -70.0):
|
||||||
|
for ramc in (60.0, 100.0, 200.0, 270.0, 300.0):
|
||||||
|
asc = H.compute_asc(ramc, eps, lat)
|
||||||
|
mc = H.compute_mc(ramc, eps)
|
||||||
|
offset = (asc - mc) % 360.0
|
||||||
|
assert 0.0 <= offset <= 180.0, (
|
||||||
|
f"Asc poza półkolem wschodzącym: lat={lat} ramc={ramc} offset={offset:.4f}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_sign_is_deterministic_at_sign_boundary():
|
||||||
|
"""REGRESJA: Ascendent o włos od granicy znaku (359,999999999976 vs 1e-10 —
|
||||||
|
ta sama wartość, różne strony) przerzucał cały dom I o 30°. Ten sam horoskop
|
||||||
|
na innej maszynie dawał inny wynik."""
|
||||||
|
below = H.cusps(359.999999999976, 270.0, H.WHOLE_SIGN)
|
||||||
|
above = H.cusps(1e-10, 270.0, H.WHOLE_SIGN)
|
||||||
|
assert below[0] == above[0] == 0.0, (below[0], above[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_polar_circle_moves_with_obliquity():
|
||||||
|
"""Granica dziedziny Placidusa/Kocha NIE jest stałą 66,56° — zależy od ε,
|
||||||
|
a ε zmienia się z datą. Testy brzegowe muszą ją liczyć per data."""
|
||||||
|
assert H.polar_circle(23.4393) == pytest.approx(66.5607, abs=1e-4) # dziś
|
||||||
|
assert H.polar_circle(23.747) == pytest.approx(66.253, abs=1e-4) # 370 p.n.e.
|
||||||
|
|
||||||
|
|
||||||
|
# ── systemy egzotyczne o zamkniętym wzorze (Etap 1) ──────────────────────
|
||||||
|
# Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które
|
||||||
|
# muszą zachodzić także bez swissepha (czyli w każdym środowisku).
|
||||||
|
|
||||||
|
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus",
|
||||||
|
"topocentric")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("system", EXOTIC)
|
||||||
|
def test_exotic_returns_twelve_cusps_in_range(system):
|
||||||
|
out = H.cusps_for(100.0, 23.4393, 50.0, system)
|
||||||
|
assert len(out) == 12
|
||||||
|
assert all(0.0 <= c < 360.0 for c in out)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("system", EXOTIC)
|
||||||
|
def test_opposite_houses_are_antipodal(system):
|
||||||
|
"""Domy przeciwległe leżą na tym samym kole wielkim, więc ich cuspy są
|
||||||
|
dokładnie antypodyczne. Naruszenie tego oznacza błąd w wyborze gałęzi."""
|
||||||
|
out = H.cusps_for(137.0, 23.4393, 42.0, system)
|
||||||
|
for i in range(6):
|
||||||
|
assert abs(((out[i + 6] - out[i]) % 360.0) - 180.0) < 1e-9, f"domy {i+1}/{i+7}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("system", ("regiomontanus", "campanus", "alcabitus"))
|
||||||
|
def test_quadrant_systems_anchor_on_ascendant(system):
|
||||||
|
"""Systemy kwadrantowe zaczynają dom I na Ascendencie."""
|
||||||
|
ramc, eps, lat = 100.0, 23.4393, 50.0
|
||||||
|
assert H.cusps_for(ramc, eps, lat, system)[0] == pytest.approx(
|
||||||
|
H.compute_asc(ramc, eps, lat), abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vehlow_puts_ascendant_in_the_middle_of_house_one():
|
||||||
|
ramc, eps, lat = 100.0, 23.4393, 50.0
|
||||||
|
asc = H.compute_asc(ramc, eps, lat)
|
||||||
|
assert H.cusps_for(ramc, eps, lat, "vehlow")[0] == pytest.approx(
|
||||||
|
H.norm360(asc - 15.0), abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_morinus_ignores_latitude():
|
||||||
|
"""Morinus rzutuje równik wprost na ekliptykę, bez horyzontu — jako jedyny
|
||||||
|
nie zależy od szerokości geograficznej."""
|
||||||
|
a = H.cusps_for(100.0, 23.4393, 20.0, "morinus")
|
||||||
|
b = H.cusps_for(100.0, 23.4393, 65.0, "morinus")
|
||||||
|
assert a == pytest.approx(b, abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_system_is_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
H.cusps_for(100.0, 23.4393, 50.0, "nie-ma-takiego")
|
||||||
|
|
||||||
|
|
||||||
|
# ── systemy łuku dobowego: Placidus i Koch (Etap 2) ──────────────────────
|
||||||
|
|
||||||
|
DIURNAL = ("placidus", "koch")
|
||||||
|
TROMSO_LAT = 69.65 # za kołem podbiegunowym — tam te systemy nie istnieją
|
||||||
|
KRAKOW_LAT = 50.06
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("system", DIURNAL)
|
||||||
|
def test_diurnal_systems_refuse_beyond_the_polar_circle(system):
|
||||||
|
"""Odmowa, nie liczba. Cicha podmiana systemu jest niewykrywalna z wykresu."""
|
||||||
|
with pytest.raises(H.HouseSystemUndefined):
|
||||||
|
H.cusps_for(100.0, 23.4393, TROMSO_LAT, system)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("system", DIURNAL)
|
||||||
|
def test_fallback_says_what_it_did(system):
|
||||||
|
cs = H.cusps_detailed(100.0, 23.4393, TROMSO_LAT, system)
|
||||||
|
assert cs.is_fallback and cs.system == H.FALLBACK_SYSTEM and cs.requested == system
|
||||||
|
assert len(cs.cusps) == 12
|
||||||
|
assert cs.notice and system in cs.notice and H.FALLBACK_SYSTEM in cs.notice
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("system", DIURNAL)
|
||||||
|
def test_no_fallback_and_no_notice_where_the_system_exists(system):
|
||||||
|
cs = H.cusps_detailed(100.0, 23.4393, KRAKOW_LAT, system)
|
||||||
|
assert not cs.is_fallback and cs.notice is None
|
||||||
|
assert cs.cusps == H.cusps_for(100.0, 23.4393, KRAKOW_LAT, system)
|
||||||
|
|
||||||
|
|
||||||
|
def test_placidus_cusps_satisfy_their_own_definition():
|
||||||
|
"""Sprawdzian NIEZALEŻNY od wyroczni: czy znaleziony punkt naprawdę przebył
|
||||||
|
zadany ułamek swojego półłuku. Wyrocznia mówi „zgadza się z swissephem",
|
||||||
|
to mówi „zgadza się z definicją Placidusa" — i działa bez swissepha."""
|
||||||
|
ramc, eps, lat = 212.9986, 23.4393, 66.3144 # tuż pod kołem, najtrudniej
|
||||||
|
eps_rad = math.radians(eps)
|
||||||
|
c = H.cusps_for(ramc, eps, lat, "placidus")
|
||||||
|
for house, (frac, nocturnal) in ((11, (1 / 3, False)), (12, (2 / 3, False)),
|
||||||
|
(2, (2 / 3, True)), (3, (1 / 3, True))):
|
||||||
|
lon = c[house - 1]
|
||||||
|
dec = H._declination_of_ecliptic_lon(lon, eps_rad)
|
||||||
|
ad = H._ascensional_difference(dec, lat)
|
||||||
|
ra = math.degrees(math.atan2(math.sin(math.radians(lon)) * math.cos(eps_rad),
|
||||||
|
math.cos(math.radians(lon))))
|
||||||
|
want = (ramc + 180.0 - frac * (90.0 - ad) if nocturnal
|
||||||
|
else ramc + frac * (90.0 + ad))
|
||||||
|
assert abs((ra - want + 180.0) % 360.0 - 180.0) < 1e-9, f"dom {house}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_koch_reduces_to_equal_spacing_on_the_equator():
|
||||||
|
"""Na równiku półłuk każdego stopnia to dokładnie 90°, więc trójdzielenie
|
||||||
|
czasu daje Ascendenty co 30° czasu gwiazdowego — dobry test zdrowia wzoru."""
|
||||||
|
ramc, eps = 100.0, 23.4393
|
||||||
|
c = H.cusps_for(ramc, eps, 0.0, "koch")
|
||||||
|
for house, offset in ((11, -60.0), (12, -30.0), (2, 30.0), (3, 60.0)):
|
||||||
|
assert c[house - 1] == pytest.approx(
|
||||||
|
H.compute_asc(ramc + offset, eps, 0.0), abs=1e-9), f"dom {house}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_polar_circle_boundary_moves_with_the_date():
|
||||||
|
"""Granica dziedziny NIE jest stałą 66,56° — zależy od ε, czyli od daty."""
|
||||||
|
lat = 66.60
|
||||||
|
assert lat > H.polar_circle(23.4393) # dziś: poza dziedziną
|
||||||
|
assert lat < H.polar_circle(23.3528) # rok 2270: już w dziedzinie
|
||||||
|
with pytest.raises(H.HouseSystemUndefined):
|
||||||
|
H.cusps_for(100.0, 23.4393, lat, "placidus")
|
||||||
|
assert len(H.cusps_for(100.0, 23.3528, lat, "placidus")) == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_topocentric_cusps_stay_in_order_between_mc_and_ascendant():
|
||||||
|
"""Regresja na to, co topocentrica psuło najdłużej — wybór gałęzi.
|
||||||
|
|
||||||
|
Cuspy 11 i 12 to punkty CIĄGŁEJ rodziny biegnącej od MC do Ascendentu, więc
|
||||||
|
czwórka MC → 11 → 12 → Asc leży na okręgu w kolejności, bez wyprzedzania się.
|
||||||
|
Zły wybór gałęzi przerzuca któryś punkt o 180° i kolejność pęka.
|
||||||
|
|
||||||
|
Kierunek celowo NIE jest ustalony: przy dużych szerokościach domy biegną
|
||||||
|
wstecz (przy φ=−79,55° MC=306,8°, a dom 11 to 291,4°) i to jest poprawne —
|
||||||
|
wyrocznia zwraca tam dokładnie to samo. Sprawdzamy więc, że kolejność trzyma
|
||||||
|
się w JEDNĄ ze stron, a nie że rośnie."""
|
||||||
|
def d(a, b):
|
||||||
|
return H.norm360(b - a)
|
||||||
|
|
||||||
|
for lat in (-66.0, -50.0, -20.0, 0.0, 20.0, 50.06, 66.0):
|
||||||
|
c = H.cusps_for(129.2387, 23.4393, lat, "topocentric")
|
||||||
|
mc, c11, c12, asc = c[9], c[10], c[11], c[0]
|
||||||
|
forward = d(mc, c11) + d(c11, c12) + d(c12, asc)
|
||||||
|
backward = d(c11, mc) + d(c12, c11) + d(asc, c12)
|
||||||
|
assert (abs(forward - d(mc, asc)) < 1e-9
|
||||||
|
or abs(backward - d(asc, mc)) < 1e-9), \
|
||||||
|
f"φ={lat}: MC={mc:.3f} 11={c11:.3f} 12={c12:.3f} Asc={asc:.3f} nie po kolei"
|
||||||
|
|
||||||
|
|
||||||
|
def test_topocentric_refuses_where_its_houses_stop_partitioning_the_circle():
|
||||||
|
"""Topocentric ma granicę INNEGO rodzaju niż Placidus i Koch.
|
||||||
|
|
||||||
|
Same cuspy są poprawne wszędzie (zgodne z wyrocznią co do zera), ale powyżej
|
||||||
|
koła podbiegunowego przestają dzielić okrąg — cusp VII (= I + 180°) wypada
|
||||||
|
przed cuspem VI i domy nachodzą na siebie. Przypisanie planety do domu traci
|
||||||
|
wtedy sens, więc odmawiamy zamiast zwracać liczbę bez znaczenia.
|
||||||
|
|
||||||
|
Próg jest WYPROWADZONY z warunku „dwanaście cuspów sumuje się do 360°",
|
||||||
|
a nie dobrany pod wynik testu — i wypada na kole podbiegunowym."""
|
||||||
|
for lat in (0.0, 50.06, 66.0):
|
||||||
|
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
|
||||||
|
for lat in (69.65, 89.9, -89.9):
|
||||||
|
with pytest.raises(H.HouseSystemUndefined):
|
||||||
|
H.cusps_for(100.0, 23.4393, lat, "topocentric")
|
||||||
|
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
|
||||||
|
assert cs.is_fallback and cs.notice and "topocentric" in cs.notice
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_sign_aries_ignores_the_ascendant():
|
||||||
|
"""Wariant „od Barana": dom I zaczyna się na 0° Barana niezależnie od tego,
|
||||||
|
co wschodzi. To odróżnia go od zwykłego whole sign."""
|
||||||
|
for lat in (0.0, 50.06, -33.9):
|
||||||
|
c = H.cusps_for(100.0, 23.4393, lat, "whole_sign_aries")
|
||||||
|
assert c == [pytest.approx(30.0 * i) for i in range(12)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_equal_mc_starts_the_tenth_house_exactly_on_the_midheaven():
|
||||||
|
"""W `equal` MC leży GDZIEŚ w domu X; w `equal_mc` zaczyna go dokładnie."""
|
||||||
|
ramc, eps, lat = 100.0, 23.4393, 50.0
|
||||||
|
mc = H.compute_mc(ramc, eps)
|
||||||
|
c = H.cusps_for(ramc, eps, lat, "equal_mc")
|
||||||
|
assert c[9] == pytest.approx(mc, abs=1e-9) # dom X rusza na MC
|
||||||
|
assert c[0] == pytest.approx(H.norm360(mc + 90.0), abs=1e-9)
|
||||||
|
for i in range(12): # nadal równe 30°
|
||||||
|
assert H.norm360(c[(i + 1) % 12] - c[i]) == pytest.approx(30.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
# ── nachylenie ekliptyki: prawdziwe, nie średnie ─────────────────────────
|
||||||
|
|
||||||
|
def test_sidereal_returns_true_obliquity_not_mean():
|
||||||
|
"""RAMC liczymy z GAST — czasu gwiazdowego POZORNEGO, mierzonego od
|
||||||
|
równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej samej równonocy ma ε
|
||||||
|
Z NUTACJĄ. Sparowanie GAST z ε średnim mieszało dwa układy odniesienia
|
||||||
|
i przesuwało cuspy domów o ~3″.
|
||||||
|
|
||||||
|
Framework wyroczni tego NIE wykryje: z założenia podaje to samo ε obu
|
||||||
|
stronom, żeby izolować samą funkcję domów. Dlatego sprawdzian jest tutaj,
|
||||||
|
ze Skyfieldem jako niezależnym autorytetem."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from skyfield.api import load
|
||||||
|
from skyfield.nutationlib import mean_obliquity
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.skyfield_engine import SkyfieldEngine, true_obliquity
|
||||||
|
|
||||||
|
when = datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc)
|
||||||
|
t = load.timescale().from_datetime(when)
|
||||||
|
_, eps = SkyfieldEngine().sidereal(ChartMoment(when_utc=when, lat=50.06, lon=19.94))
|
||||||
|
|
||||||
|
assert eps == pytest.approx(true_obliquity(t), abs=1e-12)
|
||||||
|
eps_mean = float(mean_obliquity(t.tdb)) / 3600.0
|
||||||
|
assert eps != pytest.approx(eps_mean, abs=1e-9), "ε jest średnie — nutacja zgubiona"
|
||||||
|
# nutacja w nachyleniu nie przekracza ~9,2″; większa różnica to nie nutacja
|
||||||
|
assert abs(eps - eps_mean) * 3600.0 < 9.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_true_obliquity_stays_in_a_sane_band_across_the_date_range():
|
||||||
|
"""ε maleje ~0,013°/wiek. Na całym zakresie programu (370 p.n.e. – 2270 n.e.)
|
||||||
|
musi zostać w widełkach 23,2–23,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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -114,8 +114,15 @@ def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
|
|||||||
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
|
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
|
||||||
|
|
||||||
|
|
||||||
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
|
# Co stoi po lewej stronie koła (LOG-05: „warianty 0°Aries / fixed Asc").
|
||||||
phi = math.radians(180.0 - (lon - asc))
|
ASC_LEFT = "asc"
|
||||||
|
ARIES_LEFT = "aries"
|
||||||
|
ORIENTATIONS = ((ASC_LEFT, "Ascendent po lewej"), (ARIES_LEFT, "0° Barana po lewej"))
|
||||||
|
|
||||||
|
|
||||||
|
def _pt(lon: float, ref: float, r: float) -> tuple[float, float]:
|
||||||
|
"""Punkt na kole dla długości `lon`, przy `ref` po lewej stronie rysunku."""
|
||||||
|
phi = math.radians(180.0 - (lon - ref))
|
||||||
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
|
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
|
||||||
|
|
||||||
|
|
||||||
@@ -206,12 +213,19 @@ def _delta(a: float, b: float) -> float:
|
|||||||
return abs(((a - b + 180.0) % 360.0) - 180.0)
|
return abs(((a - b + 180.0) % 360.0) - 180.0)
|
||||||
|
|
||||||
|
|
||||||
def render(chart: dict, theme: str = "screen") -> str:
|
def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> str:
|
||||||
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
|
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
|
||||||
|
|
||||||
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
|
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
|
||||||
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
|
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
|
||||||
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
|
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
|
||||||
|
|
||||||
|
orientation — co stoi po LEWEJ stronie koła (LOG-05, warianty rysunku):
|
||||||
|
'asc' — Ascendent (domyślne; tak rysuje większość szkół zachodnich,
|
||||||
|
dom I zawsze zaczyna się w tym samym miejscu rysunku),
|
||||||
|
'aries' — 0° Barana (koło nieruchome względem zodiaku, więc dwa horoskopy
|
||||||
|
da się porównywać „na oko"; osie wypadają za to gdzie indziej).
|
||||||
|
Zmienia się WYŁĄCZNIE obrót rysunku — żadna liczba nie jest przeliczana.
|
||||||
"""
|
"""
|
||||||
T = _THEMES.get(theme, _THEMES["screen"])
|
T = _THEMES.get(theme, _THEMES["screen"])
|
||||||
# tylko dla druku — na stronie font podaje CSS (.glyph)
|
# tylko dla druku — na stronie font podaje CSS (.glyph)
|
||||||
@@ -223,6 +237,9 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
asc = float(angles["Asc"]["decimal"])
|
asc = float(angles["Asc"]["decimal"])
|
||||||
|
# Oś obrotu rysunku. Długości ekliptyczne zostają nietknięte — przesuwamy
|
||||||
|
# tylko punkt, który ląduje po lewej stronie koła.
|
||||||
|
ref = 0.0 if orientation == ARIES_LEFT else asc
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
|
||||||
# tło i okręgi
|
# tło i okręgi
|
||||||
@@ -244,8 +261,8 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
continue
|
continue
|
||||||
color = T["aspects"].get(asp.get("aspect"), T["muted"])
|
color = T["aspects"].get(asp.get("aspect"), T["muted"])
|
||||||
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
|
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
|
||||||
ax1, ay1 = _pt(la, asc, R_HUB)
|
ax1, ay1 = _pt(la, ref, R_HUB)
|
||||||
bx1, by1 = _pt(lb, asc, R_HUB)
|
bx1, by1 = _pt(lb, ref, R_HUB)
|
||||||
tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} '
|
tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} '
|
||||||
f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°')
|
f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°')
|
||||||
parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
|
parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
|
||||||
@@ -253,17 +270,17 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
|
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
|
||||||
for deg in range(0, 360, 5):
|
for deg in range(0, 360, 5):
|
||||||
r_in = R_TICK if deg % 10 else R_TICK - 4
|
r_in = R_TICK if deg % 10 else R_TICK - 4
|
||||||
x1, y1 = _pt(deg, asc, R_ZOD)
|
x1, y1 = _pt(deg, ref, R_ZOD)
|
||||||
x2, y2 = _pt(deg, asc, r_in)
|
x2, y2 = _pt(deg, ref, r_in)
|
||||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
|
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
|
||||||
|
|
||||||
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
|
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
|
||||||
for i, sg in enumerate(signs):
|
for i, sg in enumerate(signs):
|
||||||
b = 30.0 * i
|
b = 30.0 * i
|
||||||
x1, y1 = _pt(b, asc, R_ZOD)
|
x1, y1 = _pt(b, ref, R_ZOD)
|
||||||
x2, y2 = _pt(b, asc, R_OUT)
|
x2, y2 = _pt(b, ref, R_OUT)
|
||||||
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
|
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
|
||||||
gx, gy = _pt(b + 15.0, asc, R_SIGN)
|
gx, gy = _pt(b + 15.0, ref, R_SIGN)
|
||||||
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
|
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
|
||||||
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
|
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
|
||||||
|
|
||||||
@@ -271,31 +288,31 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
n = len(cusps)
|
n = len(cusps)
|
||||||
for i, c in enumerate(cusps):
|
for i, c in enumerate(cusps):
|
||||||
lon = float(c["decimal"])
|
lon = float(c["decimal"])
|
||||||
x1, y1 = _pt(lon, asc, R_ZOD)
|
x1, y1 = _pt(lon, ref, R_ZOD)
|
||||||
x2, y2 = _pt(lon, asc, R_HUB)
|
x2, y2 = _pt(lon, ref, R_HUB)
|
||||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
|
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
|
||||||
nxt = float(cusps[(i + 1) % n]["decimal"])
|
nxt = float(cusps[(i + 1) % n]["decimal"])
|
||||||
span = (nxt - lon) % 360.0 or 360.0
|
span = (nxt - lon) % 360.0 or 360.0
|
||||||
mid = lon + span / 2.0
|
mid = lon + span / 2.0
|
||||||
hx, hy = _pt(mid, asc, R_HNUM)
|
hx, hy = _pt(mid, ref, R_HNUM)
|
||||||
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
|
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
|
||||||
# Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy
|
# Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy
|
||||||
# są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer;
|
# są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer;
|
||||||
# w systemach kwadratowych ta liczba realnie coś mówi.
|
# w systemach kwadratowych ta liczba realnie coś mówi.
|
||||||
deg_in_sign = lon % 30.0
|
deg_in_sign = lon % 30.0
|
||||||
if deg_in_sign >= 0.5:
|
if deg_in_sign >= 0.5:
|
||||||
cx1, cy1 = _pt(lon, asc, R_ZOD - 9)
|
cx1, cy1 = _pt(lon, ref, R_ZOD - 9)
|
||||||
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
|
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
|
||||||
|
|
||||||
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
|
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
|
||||||
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
|
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
|
||||||
lon_a = float(angles[a]["decimal"])
|
lon_a = float(angles[a]["decimal"])
|
||||||
ax, ay = _pt(lon_a, asc, R_ZOD)
|
ax, ay = _pt(lon_a, ref, R_ZOD)
|
||||||
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
|
bx, by = _pt(lon_a + 180.0, ref, R_ZOD)
|
||||||
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
|
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
|
||||||
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
|
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
|
||||||
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
|
lax, lay = _pt(lon_a, ref, R_AXIS_LABEL)
|
||||||
lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
|
lbx, lby = _pt(lon_a + 180.0, ref, R_AXIS_LABEL)
|
||||||
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
|
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
|
||||||
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
|
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
|
||||||
|
|
||||||
@@ -310,15 +327,15 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
|
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
|
||||||
retro = obj.get("direction") == "Rx"
|
retro = obj.get("direction") == "Rx"
|
||||||
|
|
||||||
mx1, my1 = _pt(true_lon, asc, R_ZOD)
|
mx1, my1 = _pt(true_lon, ref, R_ZOD)
|
||||||
mx2, my2 = _pt(true_lon, asc, R_MARK)
|
mx2, my2 = _pt(true_lon, ref, R_MARK)
|
||||||
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
|
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
|
||||||
|
|
||||||
if _delta(draw_lon, true_lon) > 0.4:
|
if _delta(draw_lon, true_lon) > 0.4:
|
||||||
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
|
lx, ly = _pt(draw_lon, ref, R_PLANET + 12)
|
||||||
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
|
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
|
||||||
|
|
||||||
gx, gy = _pt(draw_lon, asc, R_PLANET)
|
gx, gy = _pt(draw_lon, ref, R_PLANET)
|
||||||
tip = obj.get("name") or ""
|
tip = obj.get("name") or ""
|
||||||
if obj.get("in_sign"):
|
if obj.get("in_sign"):
|
||||||
tip += f" · {obj['in_sign']}"
|
tip += f" · {obj['in_sign']}"
|
||||||
@@ -332,7 +349,7 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
|
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
|
||||||
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
|
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
|
||||||
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
|
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
|
||||||
dx, dy = _pt(draw_lon, asc, R_DEG)
|
dx, dy = _pt(draw_lon, ref, R_DEG)
|
||||||
label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
|
label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
|
||||||
parts.append(_text(dx, dy, label, size=8,
|
parts.append(_text(dx, dy, label, size=8,
|
||||||
fill=T["retro"] if retro else T["muted"]))
|
fill=T["retro"] if retro else T["muted"]))
|
||||||
@@ -346,10 +363,10 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
if not glyph or lot.get("longitude") is None:
|
if not glyph or lot.get("longitude") is None:
|
||||||
continue
|
continue
|
||||||
lon = float(lot["longitude"])
|
lon = float(lot["longitude"])
|
||||||
mx1, my1 = _pt(lon, asc, R_ZOD)
|
mx1, my1 = _pt(lon, ref, R_ZOD)
|
||||||
mx2, my2 = _pt(lon, asc, R_MARK)
|
mx2, my2 = _pt(lon, ref, R_MARK)
|
||||||
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
|
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
|
||||||
lx, ly = _pt(lon, asc, R_LOT)
|
lx, ly = _pt(lon, ref, R_LOT)
|
||||||
tip = lot.get("name") or ""
|
tip = lot.get("name") or ""
|
||||||
if lot.get("in_sign"):
|
if lot.get("in_sign"):
|
||||||
tip += f" · {lot['in_sign']}"
|
tip += f" · {lot['in_sign']}"
|
||||||
@@ -367,7 +384,6 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
|
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
|
||||||
ASP_CELL = 30.0 # bok komórki siatki aspektów
|
ASP_CELL = 30.0 # bok komórki siatki aspektów
|
||||||
|
|
||||||
|
|
||||||
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
|
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
|
||||||
"""Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG.
|
"""Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG.
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ class LogicClient:
|
|||||||
client, f"{self.base_url}/chart/horoscope/stream",
|
client, f"{self.base_url}/chart/horoscope/stream",
|
||||||
payload=payload, headers=_auth_headers(), link=_link())
|
payload=payload, headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
def bases(self) -> dict[str, Any]:
|
||||||
|
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
||||||
|
headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
def llm_models(self) -> dict[str, Any]:
|
def llm_models(self) -> dict[str, Any]:
|
||||||
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Katalog systemów domów dla warstwy prezentacji — JEDNO źródło nazw.
|
||||||
|
|
||||||
|
Nazwy były zaszyte w czterech szablonach naraz (selektor na „Horoskop", selektor
|
||||||
|
na „Skompiluj", checkboxy porównania, nagłówki tabeli). Przy trzech systemach
|
||||||
|
uchodziło to na sucho; przy dziesięciu rozjazd jest kwestią czasu — wystarczy
|
||||||
|
dopisać system w jednym miejscu i zapomnieć o trzech pozostałych.
|
||||||
|
|
||||||
|
Kolejność jest celowa: najpierw dwa najpowszechniejsze (Placidus i Whole Sign),
|
||||||
|
potem reszta. `LIMITED` oznacza systemy, które powyżej koła podbiegunowego nie
|
||||||
|
istnieją i podlegają jawnemu fallbackowi — UI ma o tym uprzedzać ZANIM ktoś
|
||||||
|
policzy horoskop dla Tromsø, a nie dopiero w ostrzeżeniu przy wyniku.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
HOUSE_SYSTEMS: list[tuple[str, str]] = [
|
||||||
|
("placidus", "Placidus"),
|
||||||
|
("whole_sign", "Whole Sign"),
|
||||||
|
("whole_sign_aries", "Whole Sign (od Barana)"),
|
||||||
|
("koch", "Koch"),
|
||||||
|
("equal", "Equal (od Asc)"),
|
||||||
|
("equal_mc", "Equal (od MC)"),
|
||||||
|
("porphyry", "Porphyry"),
|
||||||
|
("regiomontanus", "Regiomontanus"),
|
||||||
|
("campanus", "Campanus"),
|
||||||
|
("alcabitus", "Alcabitus"),
|
||||||
|
("topocentric", "Topocentric"),
|
||||||
|
("morinus", "Morinus"),
|
||||||
|
("vehlow", "Vehlow"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Systemy oparte na łuku dobowym — jedyne z realną granicą dziedziny.
|
||||||
|
LIMITED = frozenset({"placidus", "koch"})
|
||||||
|
|
||||||
|
HOUSE_SYSTEM_NAMES: dict[str, str] = dict(HOUSE_SYSTEMS)
|
||||||
|
|
||||||
|
|
||||||
|
def label(system: str) -> str:
|
||||||
|
"""Nazwa do pokazania; nieznany identyfikator zwracamy bez zmian, nie gubimy go."""
|
||||||
|
return HOUSE_SYSTEM_NAMES.get(system, system)
|
||||||
@@ -18,22 +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 features
|
||||||
|
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).
|
||||||
@@ -54,6 +64,50 @@ def static_url(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
templates.env.globals["static"] = static_url
|
templates.env.globals["static"] = static_url
|
||||||
|
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
|
||||||
|
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
|
||||||
|
templates.env.globals["house_label"] = house_label
|
||||||
|
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
|
||||||
|
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]:
|
||||||
@@ -100,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,
|
||||||
@@ -124,28 +188,35 @@ def chart_compute(
|
|||||||
stations: bool = Form(False),
|
stations: bool = Form(False),
|
||||||
zodiac: str = Form("tropical"),
|
zodiac: str = Form("tropical"),
|
||||||
tables: bool = Form(False),
|
tables: bool = Form(False),
|
||||||
|
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
|
||||||
):
|
):
|
||||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"lat": lat, "lon": lon, "house_system": house_system,
|
"lat": lat, "lon": lon, "house_system": house_system,
|
||||||
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||||||
"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}
|
||||||
|
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"])
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
orientation=form["wheel_orientation"])
|
||||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
|
||||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
|
||||||
|
if "extra_charts" in _perms(request):
|
||||||
|
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||||
|
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||||||
|
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -179,6 +250,7 @@ def compile_build(
|
|||||||
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
|
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
|
||||||
zodiac: str = Form("tropical"),
|
zodiac: str = Form("tropical"),
|
||||||
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
|
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
|
||||||
|
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
|
||||||
):
|
):
|
||||||
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
|
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
|
||||||
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
||||||
@@ -193,23 +265,27 @@ def compile_build(
|
|||||||
"lat": lat, "lon": lon, "house_system": house_system,
|
"lat": lat, "lon": lon, "house_system": house_system,
|
||||||
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||||||
"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}
|
||||||
|
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"])
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
orientation=form["wheel_orientation"])
|
||||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
if "extra_charts" in _perms(request):
|
||||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||||
|
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||||||
|
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -242,6 +318,7 @@ def compile_pdf(payload: dict):
|
|||||||
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
||||||
|
|
||||||
figures: list[dict] = []
|
figures: list[dict] = []
|
||||||
|
warnings: list[str] = []
|
||||||
try:
|
try:
|
||||||
chart = logic.positions(
|
chart = logic.positions(
|
||||||
when_utc_iso=iso_utc,
|
when_utc_iso=iso_utc,
|
||||||
@@ -254,13 +331,19 @@ def compile_pdf(payload: dict):
|
|||||||
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
|
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
|
||||||
zodiac=str(data.get("zodiac") or "tropical"),
|
zodiac=str(data.get("zodiac") or "tropical"),
|
||||||
)
|
)
|
||||||
|
# Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu
|
||||||
|
# nie da się poznać, że podział jest z innego systemu, niż zamówiono.
|
||||||
|
warnings = [w for w in (chart.get("house_warnings") or []) if w]
|
||||||
|
|
||||||
from app import chartwheel
|
from app import chartwheel
|
||||||
|
|
||||||
# Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
|
# Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
|
||||||
# w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
|
# w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
|
||||||
# zmienne CSS i font glifów muszą być wprost w rysunku.
|
# zmienne CSS i font glifów muszą być wprost w rysunku.
|
||||||
for svg, caption in (
|
for svg, caption in (
|
||||||
(chartwheel.render(chart, theme="print"), "Kosmogram"),
|
(chartwheel.render(chart, theme="print",
|
||||||
|
orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)),
|
||||||
|
"Kosmogram"),
|
||||||
(chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
|
(chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
|
||||||
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
|
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
|
||||||
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
|
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
|
||||||
@@ -275,6 +358,7 @@ def compile_pdf(payload: dict):
|
|||||||
"person": payload.get("person") or "",
|
"person": payload.get("person") or "",
|
||||||
"data": {**data, "moment_utc": label},
|
"data": {**data, "moment_utc": label},
|
||||||
"figures": figures,
|
"figures": figures,
|
||||||
|
"warnings": warnings,
|
||||||
"natal": payload.get("natal") or {},
|
"natal": payload.get("natal") or {},
|
||||||
"predictions": payload.get("predictions") or [],
|
"predictions": payload.get("predictions") or [],
|
||||||
}
|
}
|
||||||
@@ -289,6 +373,23 @@ def compile_pdf(payload: dict):
|
|||||||
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
|
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ----------------
|
||||||
|
@app.get("/settings", response_class=HTMLResponse)
|
||||||
|
def settings_view(request: Request):
|
||||||
|
"""Podgląd baz dostępnych na udziale + które biorą udział w interpretacji.
|
||||||
|
|
||||||
|
Same metadane (nazwa, rozmiar, data) — bez treści baz. Przełączniki są
|
||||||
|
DEKLARATYWNE (`DISABLED_BASES`), bo warstwa danych nie ma trwałego zapisu:
|
||||||
|
udział jest read-only, a cache to emptyDir. Zapis do pliku ginąłby po
|
||||||
|
restarcie, po cichu włączając z powrotem wyłączoną bazę."""
|
||||||
|
ctx: dict = {"result": None, "error": None}
|
||||||
|
try:
|
||||||
|
ctx["result"] = logic.bases()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
return templates.TemplateResponse(request, "settings.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Synastria (technika relacyjna) ----------------
|
# ---------------- Synastria (technika relacyjna) ----------------
|
||||||
@app.get("/synastry", response_class=HTMLResponse)
|
@app.get("/synastry", response_class=HTMLResponse)
|
||||||
def synastry_form(request: Request):
|
def synastry_form(request: Request):
|
||||||
@@ -571,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"}
|
||||||
|
|||||||
@@ -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,11 +105,48 @@ 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; }
|
||||||
|
|
||||||
|
/* Fallback systemu domów. Celowo NIE jest to dyskretny „muted" — po fallbacku
|
||||||
|
kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
|
||||||
|
podział domów pochodzi z innego systemu, niż zamówiono. */
|
||||||
|
.house-warning {
|
||||||
|
margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
|
||||||
|
border: 1px solid var(--warn-border, #b8860b);
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--warn-bg, rgba(184, 134, 11, .10));
|
||||||
|
color: var(--warn-fg, inherit);
|
||||||
|
}
|
||||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
.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; }
|
||||||
@@ -177,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,21 +1,38 @@
|
|||||||
{# 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
|
||||||
<label><input type="checkbox" name="house_systems" value="whole_sign" {{ 'checked' if 'whole_sign' in chosen else '' }}> Whole Sign</label>
|
<em class="hint">— gwiazdka: nie działa za kołem podbiegunowym (ok. 66,5°); tam policzymy Porphyry i wyraźnie o tym napiszemy</em>
|
||||||
<label><input type="checkbox" name="house_systems" value="equal" {{ 'checked' if 'equal' in chosen else '' }}> Equal</label>
|
</span>
|
||||||
<label><input type="checkbox" name="house_systems" value="porphyry" {{ 'checked' if 'porphyry' in chosen else '' }}> Porphyry</label>
|
{% for value, name in HOUSE_SYSTEMS %}
|
||||||
|
<label><input type="checkbox" name="house_systems" value="{{ value }}" {{ 'checked' if value in chosen else '' }}> {{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</label>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if can(request, 'houses_compare') %}
|
||||||
|
<div class="opts" title="Zmienia wyłącznie OBRÓT rysunku — żadna liczba nie jest przeliczana. „Ascendent po lewej” to tradycja zachodnia: dom I zawsze zaczyna się w tym samym miejscu. „0° Barana po lewej” unieruchamia koło względem zodiaku, dzięki czemu dwa horoskopy da się porównywać na oko.">
|
||||||
|
{% set wo = form.wheel_orientation or 'asc' %}
|
||||||
|
<span class="muted small">Kosmogram — po lewej stronie:</span>
|
||||||
|
{% for value, name in WHEEL_ORIENTATIONS %}
|
||||||
|
<label><input type="radio" name="wheel_orientation" value="{{ value }}" {{ 'checked' if wo == value else '' }}> {{ name }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if can(request, 'advanced_calc') %}
|
||||||
<div class="opts" title="Orb = dopuszczalne odchylenie od dokładnego kąta aspektu. „Bonus świateł" powiększa orb dla aspektów ze Słońcem/Księżycem. Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150° (PRE-06).">
|
<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 %}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
deklinacja/antyscja) renderuje rodzic; tu są same tabele. Każda sekcja pokazuje
|
deklinacja/antyscja) renderuje rodzic; tu są same tabele. Każda sekcja pokazuje
|
||||||
się tylko, gdy jej dane są w wyniku (opcja policzona → sekcja jest). #}
|
się tylko, gdy jej dane są w wyniku (opcja policzona → sekcja jest). #}
|
||||||
|
|
||||||
|
{# Fallback systemu domów: system nie istniał dla tego miejsca i policzyliśmy
|
||||||
|
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
|
||||||
|
{% if result.house_warnings %}
|
||||||
|
{% for w in result.house_warnings %}
|
||||||
|
<p class="house-warning">{{ w }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if result.angles %}
|
{% if result.angles %}
|
||||||
<table class="angles">
|
<table class="angles">
|
||||||
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||||
@@ -20,10 +28,12 @@
|
|||||||
|
|
||||||
{# 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 %}
|
||||||
{% set HSN = {'whole_sign': 'Whole Sign', 'equal': 'Equal', 'porphyry': 'Porphyry'} %}
|
|
||||||
<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>{{ HSN.get(hs.system, 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>
|
||||||
{% for i in range(12) %}
|
{% for i in range(12) %}
|
||||||
<tr><td>{{ i + 1 }}</td>
|
<tr><td>{{ i + 1 }}</td>
|
||||||
@@ -34,6 +44,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
@@ -219,7 +230,7 @@
|
|||||||
|
|
||||||
{% if result.cusps %}
|
{% if result.cusps %}
|
||||||
<details class="loc">
|
<details class="loc">
|
||||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
<summary>Cusps domów ({{ house_label(result.house_system) }})</summary>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
|
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -13,18 +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 %}
|
||||||
</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,14 +31,17 @@
|
|||||||
<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' %}
|
||||||
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
|
{% for value, name in HOUSE_SYSTEMS %}
|
||||||
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
|
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
||||||
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</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' %}
|
||||||
@@ -49,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" %}
|
||||||
@@ -65,10 +69,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if result %}
|
{% if result %}
|
||||||
|
{# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #}
|
||||||
|
{% for w in result.house_warnings or [] %}
|
||||||
|
<p class="house-warning">{{ w }}</p>
|
||||||
|
{% endfor %}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
Silnik: <strong>{{ result.engine }}</strong> ·
|
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||||
obiektów: {{ result.positions | length }}
|
obiektów: {{ result.positions | length }}
|
||||||
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
|
{% if result.house_system %}· domy: {{ house_label(result.house_system) }}{% endif %}
|
||||||
{% if result.zodiac %}· zodiak: {{ result.zodiac }}{% if result.ayanamsha is defined %} (ayanamsa {{ '%.4f'|format(result.ayanamsha) }}°){% endif %}{% endif %}
|
{% if result.zodiac %}· zodiak: {{ result.zodiac }}{% if result.ayanamsha is defined %} (ayanamsa {{ '%.4f'|format(result.ayanamsha) }}°){% endif %}{% endif %}
|
||||||
{% if moment %}· moment: {{ moment }}{% endif %}
|
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,14 +36,17 @@ 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' %}
|
||||||
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
|
{% for value, name in HOUSE_SYSTEMS %}
|
||||||
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
|
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
||||||
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</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' %}
|
||||||
@@ -54,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>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Ustawienia{% endblock %}
|
||||||
|
{% set nav_active = "settings" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
|
||||||
|
|
||||||
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
<div class="meta">Bazy na udziale: <strong>{{ result.total }}</strong> ·
|
||||||
|
biorą udział w interpretacji: <strong>{{ result.enabled }}</strong>
|
||||||
|
{% if result.total and result.enabled < result.total %}
|
||||||
|
· wyłączonych: <strong>{{ result.total - result.enabled }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if result.bases %}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Baza</th><th>Ścieżka</th><th>Rozmiar</th><th>Zmieniono</th><th>W interpretacji</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in result.bases %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ b.name }}</td>
|
||||||
|
<td class="muted small">{{ b.path }}</td>
|
||||||
|
<td class="mono">{% if b.size_mb is not none %}{{ b.size_mb }} MB{% else %}—{% endif %}</td>
|
||||||
|
<td class="mono">{{ b.modified or '—' }}</td>
|
||||||
|
<td>{% if b.enabled %}tak{% else %}<span class="badge">wyłączona</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Na udziale nie widać żadnych baz (albo aktywny dostawca nie operuje na plikach).</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="meta">Jak wyłączyć bazę</div>
|
||||||
|
<p class="muted small">
|
||||||
|
Lista wyłączonych jest <strong>deklaratywna</strong> — trzyma ją zmienna
|
||||||
|
<span class="mono">DISABLED_BASES</span> usługi danych (nazwy plików po przecinku,
|
||||||
|
np. <span class="mono">stara_baza.xlsx,archiwum/testowa.xlsx</span>). Po zmianie
|
||||||
|
zrestartuj warstwę danych.
|
||||||
|
</p>
|
||||||
|
<p class="muted small">
|
||||||
|
Dlaczego nie klikaniem: udział z bazami jest montowany tylko do odczytu, a katalog
|
||||||
|
cache to <span class="mono">emptyDir</span> — zapisany przełącznik ginąłby przy
|
||||||
|
restarcie poda i po cichu włączał z powrotem wyłączoną bazę. Przełączanie z tego
|
||||||
|
ekranu wymagałoby dołożenia trwałego wolumenu.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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():
|
||||||
@@ -76,16 +85,45 @@ def test_compile_page_renders_all_figures():
|
|||||||
assert var in MAIN, f"/compile nie ustawia {var}"
|
assert var in MAIN, f"/compile nie ustawia {var}"
|
||||||
|
|
||||||
|
|
||||||
def test_pdf_bundles_all_figures_in_print_theme():
|
def test_pdf_bundles_all_figures_in_print_theme(monkeypatch):
|
||||||
"""compile_pdf składa KOMPLET rysunków (motyw druku) i wysyła jako `figures`."""
|
"""compile_pdf składa KOMPLET rysunków w motywie DRUKU.
|
||||||
assert '"figures": figures' in MAIN
|
|
||||||
for call in (
|
Sprawdzamy przez WYWOŁANIE trasy, nie przez szukanie tekstu w main.py:
|
||||||
'render(chart, theme="print")',
|
poprzednia wersja greppowała `render(chart, theme="print")` i pękała przy
|
||||||
'render_aspectarian(chart, theme="print")',
|
każdym dopisaniu argumentu, mimo że zachowanie zostawało poprawne."""
|
||||||
'render_declination(chart, theme="print")',
|
seen: dict[str, str] = {}
|
||||||
'render_antiscia(chart, theme="print")',
|
|
||||||
):
|
from app import chartwheel
|
||||||
assert call in MAIN, f"PDF nie składa: {call}"
|
from app.clients.render_client import RenderClient
|
||||||
|
from app.main import logic
|
||||||
|
|
||||||
|
for name in ("render", "render_aspectarian", "render_declination", "render_antiscia"):
|
||||||
|
original = getattr(chartwheel, name)
|
||||||
|
|
||||||
|
def spy(chart, theme="screen", _n=name, _o=original, **kw):
|
||||||
|
seen[_n] = theme
|
||||||
|
return _o(chart, theme=theme, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(chartwheel, name, spy)
|
||||||
|
|
||||||
|
monkeypatch.setattr(logic, "positions", lambda **kw: _pdf_sample_chart())
|
||||||
|
monkeypatch.setattr(RenderClient, "pdf", lambda self, report: b"%PDF-1.4 stub")
|
||||||
|
|
||||||
|
r = _client().post("/compile/pdf", json={"person": "Jan", "data": {
|
||||||
|
"date": "1984-04-30", "time": "11:20", "tz_offset": 2, "lat": 50.06, "lon": 19.94}})
|
||||||
|
assert r.status_code == 200, r.text[:300]
|
||||||
|
assert set(seen) == {"render", "render_aspectarian",
|
||||||
|
"render_declination", "render_antiscia"}, seen
|
||||||
|
assert set(seen.values()) == {"print"}, seen
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_sample_chart() -> dict:
|
||||||
|
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": "equal"}
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────── zbieranie materiału z magazynów ────────────────
|
# ──────────────────────── zbieranie materiału z magazynów ────────────────
|
||||||
@@ -174,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():
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ systemów, klient przekazuje je do logiki, a szablon pokazuje kuspy obok siebie.
|
|||||||
"""
|
"""
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
|
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
|
||||||
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
|
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
|
||||||
OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje
|
OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje
|
||||||
TABLES = (APP / "templates" / "_result_tables.html").read_text(encoding="utf-8") # wspólne tabele
|
TABLES = (APP / "templates" / "_result_tables.html").read_text(encoding="utf-8") # wspólne tabele
|
||||||
MAIN = (APP / "main.py").read_text(encoding="utf-8")
|
MAIN = (APP / "main.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
from app.house_systems import HOUSE_SYSTEMS, LIMITED, label as house_label # noqa: E402
|
||||||
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
|
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
@@ -20,15 +24,64 @@ def test_both_forms_use_shared_option_partial():
|
|||||||
assert '{% include "_form_options.html" %}' in COMPILE
|
assert '{% include "_form_options.html" %}' in COMPILE
|
||||||
|
|
||||||
|
|
||||||
def test_form_lets_you_pick_extra_systems():
|
def _render(name: str, **ctx) -> str:
|
||||||
"""Checkboxy dla trzech systemów — wielokrotny wybór (name powtórzony)."""
|
"""Renderuje szablon z tymi samymi globalsami, co produkcja.
|
||||||
for val in ("whole_sign", "equal", "porphyry"):
|
|
||||||
assert f'name="house_systems" value="{val}"' in OPTS, f"brak checkboxa {val}"
|
Szablony budują listę systemów PĘTLĄ po katalogu, więc sprawdzanie ich
|
||||||
|
surowego tekstu nic już nie mówi — trzeba je wykonać. Przy okazji test
|
||||||
|
łapie literówki w Jinja, których grep po źródle nigdy nie wyłapał."""
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True)
|
||||||
|
env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED,
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
def test_handler_accepts_and_passes_house_systems():
|
def test_form_offers_every_system_the_engine_advertises():
|
||||||
assert "house_systems: list[str] = Form([])" in MAIN
|
"""Każdy system z katalogu ma checkbox. Katalog jest jednym źródłem nazw,
|
||||||
assert "house_systems=house_systems" in MAIN # przekazane do logiki
|
więc dopisanie systemu bez odświeżenia UI zapali się tutaj."""
|
||||||
|
html = _render("_form_options.html", form={})
|
||||||
|
for value, name in HOUSE_SYSTEMS:
|
||||||
|
assert f'name="house_systems" value="{value}"' in html, f"brak checkboxa {value}"
|
||||||
|
assert name in html, f"brak nazwy {name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_marks_systems_with_a_domain_limit():
|
||||||
|
"""Placidus i Koch nie istnieją za kołem podbiegunowym — UI ma uprzedzać
|
||||||
|
ZANIM ktoś policzy horoskop dla Tromsø, nie dopiero w ostrzeżeniu wyniku."""
|
||||||
|
html = _render("_form_options.html", form={})
|
||||||
|
assert LIMITED, "katalog nie oznacza żadnego systemu jako ograniczonego"
|
||||||
|
for value, name in HOUSE_SYSTEMS:
|
||||||
|
starred = f"{name} *" in html
|
||||||
|
assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handler_accepts_and_passes_house_systems(monkeypatch):
|
||||||
|
"""Zaznaczone systemy DOCHODZĄ do warstwy logicznej.
|
||||||
|
|
||||||
|
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():
|
||||||
@@ -49,4 +102,130 @@ def test_comparison_table_shows_cusp_per_system():
|
|||||||
|
|
||||||
def test_checkbox_state_survives_submit():
|
def test_checkbox_state_survives_submit():
|
||||||
"""Zaznaczone systemy zostają zaznaczone po przeliczeniu."""
|
"""Zaznaczone systemy zostają zaznaczone po przeliczeniu."""
|
||||||
assert "in chosen" in OPTS and "form.house_systems" in OPTS
|
html = _render("_form_options.html", form={"house_systems": ["koch", "campanus"]})
|
||||||
|
for value, _ in HOUSE_SYSTEMS:
|
||||||
|
marker = f'value="{value}" checked'
|
||||||
|
assert (marker in html) == (value in ("koch", "campanus")), value
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_notice_is_shown_prominently():
|
||||||
|
"""Po fallbacku kosmogram wygląda bezbłędnie — ostrzeżenie jest jedynym
|
||||||
|
sygnałem, że podział domów jest z innego systemu."""
|
||||||
|
html = _render("_result_tables.html",
|
||||||
|
result={"house_warnings": ["UWAGA: system domów nie ma definicji"]})
|
||||||
|
assert "house-warning" in html
|
||||||
|
assert "UWAGA: system domów nie ma definicji" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_notice_when_nothing_was_substituted():
|
||||||
|
html = _render("_result_tables.html", result={"house_warnings": []})
|
||||||
|
assert "house-warning" not in html
|
||||||
|
|
||||||
|
|
||||||
|
# ── ścieżka PRZEZ handlery, nie obok nich ───────────────────────────────
|
||||||
|
# Dwa błędy przeszły przez komplet testów szablonowych, bo żaden nie wywołał
|
||||||
|
# POST-a: odwołanie do nieistniejącej zmiennej w handlerze strony głównej oraz
|
||||||
|
# użycie parametru formularza, którego w sygnaturze nie było. Oba dają 500 na
|
||||||
|
# żywo i oba łapie dopiero prawdziwe żądanie.
|
||||||
|
|
||||||
|
def _client_with_stub_logic(monkeypatch, chart):
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.pop("APP_PASSWORD", None)
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app, logic
|
||||||
|
|
||||||
|
monkeypatch.setattr(logic, "positions", lambda **kw: chart)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_chart():
|
||||||
|
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0°00'00''",
|
||||||
|
"decimal": float(i * 30), "sign_glyph": "♈"} for i in range(12)]
|
||||||
|
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0°00'00''", "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": "porphyry", "house_system_requested": "placidus",
|
||||||
|
"house_warnings": ["UWAGA: system domów „placidus” nie ma definicji"]}
|
||||||
|
|
||||||
|
|
||||||
|
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||||
|
"lat": "50.06", "lon": "19.94", "house_system": "placidus"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_page_survives_a_real_post(monkeypatch):
|
||||||
|
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
|
||||||
|
r = c.post("/", data=FORM)
|
||||||
|
assert r.status_code == 200, r.text[:400]
|
||||||
|
assert "nie ma definicji" in r.text, "ostrzeżenie o fallbacku nie dotarło na stronę"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_page_survives_a_real_post(monkeypatch):
|
||||||
|
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
|
||||||
|
r = c.post("/compile", data=FORM)
|
||||||
|
assert r.status_code == 200, r.text[:400]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("orientation", ("asc", "aries"))
|
||||||
|
def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
|
||||||
|
"""Wybór obrotu koła musi dojść z formularza do rysunku — i wrócić
|
||||||
|
zaznaczony, żeby nie gubił się przy przeliczeniu."""
|
||||||
|
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
|
||||||
|
r = c.post("/", data={**FORM, "wheel_orientation": orientation})
|
||||||
|
assert r.status_code == 200, r.text[:400]
|
||||||
|
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"
|
||||||
@@ -66,8 +66,12 @@ def test_key_is_read_lazily():
|
|||||||
# ──────────────────────── kosmogram w wariancie do druku ─────────────────
|
# ──────────────────────── kosmogram w wariancie do druku ─────────────────
|
||||||
|
|
||||||
def test_pdf_route_uses_print_theme():
|
def test_pdf_route_uses_print_theme():
|
||||||
"""Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów."""
|
"""Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów.
|
||||||
assert 'chartwheel.render(chart, theme="print")' in MAIN
|
|
||||||
|
Sprawdzamy, że trasa PDF-a w ogóle podaje motyw druku — bez wiązania się
|
||||||
|
z dokładnym kształtem wywołania (dochodzą do niego kolejne argumenty).
|
||||||
|
Pełny sprawdzian, że KAŻDY rysunek dostaje 'print', jest w test_compile.py."""
|
||||||
|
assert 'theme="print"' in MAIN
|
||||||
|
|
||||||
|
|
||||||
def test_print_theme_has_no_css_variables():
|
def test_print_theme_has_no_css_variables():
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Ekran ustawień: bazy na udziale (PRE-09)."""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
TPL = (APP / "templates" / "settings.html").read_text(encoding="utf-8")
|
||||||
|
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||||
|
MAIN = (APP / "main.py").read_text(encoding="utf-8")
|
||||||
|
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tab_is_in_the_menu():
|
||||||
|
"""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():
|
||||||
|
assert '@app.get("/settings"' in MAIN and "logic.bases()" in MAIN
|
||||||
|
assert 'f"{self.base_url}/bases"' in CLIENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_shows_name_and_metadata():
|
||||||
|
for col in ("b.name", "b.path", "b.size_mb", "b.modified"):
|
||||||
|
assert col in TPL, f"brak kolumny {col}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_shows_whether_base_counts_in_interpretation():
|
||||||
|
assert "b.enabled" in TPL and "wyłączona" in TPL
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_explains_how_to_disable_and_why_not_clickable():
|
||||||
|
"""Ekran ma mówić, JAK wyłączyć bazę — inaczej podgląd bez sprawczości myli."""
|
||||||
|
assert "DISABLED_BASES" in TPL
|
||||||
|
assert "emptyDir" in TPL # uzasadnienie: brak trwałego zapisu
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_from_logic_does_not_break_the_page():
|
||||||
|
assert "_logic_error(e)" in MAIN[MAIN.index("def settings_view"):MAIN.index("def settings_view") + 900]
|
||||||
@@ -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> SĄ 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
|
||||||
@@ -210,6 +210,22 @@ def build(report: dict, wheel_pdf: str | None = None,
|
|||||||
parts.append(r"\end{tabular}")
|
parts.append(r"\end{tabular}")
|
||||||
parts.append(r"\vspace{1em}")
|
parts.append(r"\vspace{1em}")
|
||||||
|
|
||||||
|
# ── 2a. ostrzeżenia o systemie domów ────────────────────────────────
|
||||||
|
# Muszą stać PRZED rysunkami: kosmogram narysowany po fallbacku wygląda
|
||||||
|
# bezbłędnie, więc jedyną informacją, że podział domów jest z innego systemu,
|
||||||
|
# jest ten akapit. W ramce, żeby nie dało się go wziąć za podpis.
|
||||||
|
warnings = [w for w in (report.get("warnings") or []) if str(w).strip()]
|
||||||
|
if warnings:
|
||||||
|
parts.append(r"\begin{center}")
|
||||||
|
parts.append(r"\fbox{\begin{minipage}{0.92\textwidth}\small")
|
||||||
|
for i, w in enumerate(warnings):
|
||||||
|
if i:
|
||||||
|
parts.append(r"\par\medskip")
|
||||||
|
parts.append(esc(str(w)))
|
||||||
|
parts.append(r"\end{minipage}}")
|
||||||
|
parts.append(r"\end{center}")
|
||||||
|
parts.append(r"\vspace{1em}")
|
||||||
|
|
||||||
# ── 3. rysunki kosmogramu ───────────────────────────────────────────
|
# ── 3. rysunki kosmogramu ───────────────────────────────────────────
|
||||||
# Jedna reguła na wszystkie: keepaspectratio z limitem SZEROKOŚCI i WYSOKOŚCI.
|
# Jedna reguła na wszystkie: keepaspectratio z limitem SZEROKOŚCI i WYSOKOŚCI.
|
||||||
# Koło i aspektarian są kwadratowe (ogranicza je wysokość), deklinacja i
|
# Koło i aspektarian są kwadratowe (ogranicza je wysokość), deklinacja i
|
||||||
|
|||||||
@@ -307,3 +307,24 @@ def test_full_ai_markdown_leaves_no_visible_markers():
|
|||||||
"###Podsekcja bez spacji\n\n- punkt **ważny**\n- i ***kluczowy***\n\n"
|
"###Podsekcja bez spacji\n\n- punkt **ważny**\n- i ***kluczowy***\n\n"
|
||||||
"#### Zamknięty ####")
|
"#### Zamknięty ####")
|
||||||
assert _no_visible_markers(md(src))
|
assert _no_visible_markers(md(src))
|
||||||
|
|
||||||
|
|
||||||
|
def test_house_fallback_warning_lands_in_the_pdf():
|
||||||
|
"""Zasada projektu: co pokazujemy na stronie, trafia do raportu i PDF-a.
|
||||||
|
|
||||||
|
Po fallbacku systemu domów kosmogram wygląda bezbłędnie, więc ten akapit
|
||||||
|
jest JEDYNĄ informacją, że podział domów pochodzi z innego systemu, niż
|
||||||
|
zamówiono. Musi też stać PRZED rysunkami, nie po nich."""
|
||||||
|
tex = build({"person": "Jan Kowalski",
|
||||||
|
"data": {"date": "1984-04-30", "house_system": "porphyry"},
|
||||||
|
"warnings": ["UWAGA: system domów „placidus” nie ma definicji"]},
|
||||||
|
figures=[{"pdf": "wheel.pdf", "caption": "Kosmogram"}])
|
||||||
|
assert "fbox" in tex
|
||||||
|
assert "nie ma definicji" in tex
|
||||||
|
assert tex.index("nie ma definicji") < tex.index("wheel.pdf"), \
|
||||||
|
"ostrzeżenie musi stać przed rysunkami"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_warning_box_when_nothing_was_substituted():
|
||||||
|
tex = build({"person": "Jan Kowalski", "data": {}, "warnings": []})
|
||||||
|
assert "fbox" not in tex
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Porównanie domów z wyrocznią (Swiss Ephemeris)
|
||||||
|
|
||||||
|
Framework testowy do systemów domów. Liczenie cuspów traktujemy jako **funkcję**
|
||||||
|
parametrów `(RAMC, ε, φ)`, a **Swiss Ephemeris jako wyrocznię** — czyli niezależną
|
||||||
|
implementację, wobec której sprawdzamy naszą.
|
||||||
|
|
||||||
|
Powstał pod implementację systemów egzotycznych (Placidus, Koch, Regiomontanus,
|
||||||
|
Campanus…), gdzie błąd jest **cichy**: wykres wygląda poprawnie, tylko planety
|
||||||
|
siedzą w złych domach. Pojedyncze cuspy przepisane z internetu tego nie wyłapią —
|
||||||
|
tysiące porównań na całej dziedzinie tak.
|
||||||
|
|
||||||
|
## Dlaczego akurat tak
|
||||||
|
|
||||||
|
**Izolacja jednostki.** Obu implementacjom podajemy TE SAME wejścia przez
|
||||||
|
`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ś,
|
||||||
|
co nie jest testowaną funkcją, i utonęlibyśmy w fałszywych alarmach.
|
||||||
|
|
||||||
|
**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
|
||||||
|
(1″)**, **maksymalne odchylenie** i **miejsce** jego wystąpienia oraz histogram.
|
||||||
|
Sam procent zgodności ukrywa kształt błędu: „97%" nie odróżnia szumu
|
||||||
|
zmiennoprzecinkowego od jednego rogu dziedziny, w którym mylimy się o 30°.
|
||||||
|
|
||||||
|
**Granice liczone per data.** Koło podbiegunowe — granica dziedziny Placidusa
|
||||||
|
i Kocha — **nie jest stałą 66,56°**. Zależy od ε, które zmienia się z datą (23,75°
|
||||||
|
w 370 p.n.e., 23,44° dziś), więc w zakresie programu przesuwa się o ~0,3°.
|
||||||
|
|
||||||
|
**Zgodność co do dziedziny.** Gdy wyrocznia odmawia liczenia (poza dziedziną),
|
||||||
|
a my uważamy, że liczymy — to też jest błąd, zgłaszany osobno. Wyrocznia mówi nam
|
||||||
|
zatem nie tylko *jakie są wartości*, ale i *gdzie kończy się dziedzina*.
|
||||||
|
|
||||||
|
## Uruchomienie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LOGIC_PATH=../../services/logic python run.py --mode build
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tryb | Co robi | Gdzie działa |
|
||||||
|
|---|---|---|
|
||||||
|
| `--mode build` | zestaw **brzegowy** + tyle samo przypadków z wnętrza | CI, **blokuje** build |
|
||||||
|
| `--mode sweep --count N --seed S` | wielki **losowy** przemiał | Job w klastrze, nie blokuje |
|
||||||
|
|
||||||
|
Kod wyjścia: `0` = zgodność w granicach tolerancji, `1` = przekroczenia albo
|
||||||
|
niezgodność dziedziny. Ziarno jest zawsze raportowane — każdy błąd da się
|
||||||
|
**odtworzyć** komendą wypisaną w raporcie.
|
||||||
|
|
||||||
|
## Co ten framework już znalazł
|
||||||
|
|
||||||
|
Uruchomiony na kodzie, który uchodził za poprawny (whole sign / equal / porphyry),
|
||||||
|
wykrył **dwa realne błędy** w pierwszym przebiegu:
|
||||||
|
|
||||||
|
1. **Ascendent o 180° za kołem podbiegunowym.** `atan2` wybierał niewłaściwy punkt
|
||||||
|
przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent. Planety lądowały
|
||||||
|
w przeciwnych domach dla całej północnej Skandynawii (Tromsø, Rovaniemi,
|
||||||
|
Murmańsk). Dotyczyło ~11% przypadków na tych szerokościach.
|
||||||
|
2. **Niedeterminizm whole sign na granicy znaku.** Ascendent o włos od granicy
|
||||||
|
(359,999999999976 vs 1e-10 — ta sama wartość, różne strony) przerzucał dom I
|
||||||
|
o 30°. Ten sam horoskop na innej maszynie mógł dać inny wynik.
|
||||||
|
|
||||||
|
Oba mają teraz testy regresji w `services/logic/tests/test_houses.py`, więc są
|
||||||
|
łapane także bez swissepha.
|
||||||
|
|
||||||
|
## Stan systemów domów
|
||||||
|
|
||||||
|
Etap 1 — systemy o **zamkniętym wzorze** (bez iteracji). Każdy poniższy przeszedł
|
||||||
|
zarówno zestaw brzegowy (2520 porównań), jak i losowy przemiał 20 000 przypadków
|
||||||
|
(240 000 porównań na system):
|
||||||
|
|
||||||
|
| System | Konstrukcja | Maks. odchylenie |
|
||||||
|
|---|---|---|
|
||||||
|
| whole sign | podział ekliptyki | 0,000000000° |
|
||||||
|
| whole sign (od Barana) | dom I zawsze na 0° Barana | 0,000000000° |
|
||||||
|
| equal | podział ekliptyki od Ascendentu | 0,000000000° |
|
||||||
|
| equal (od MC) | dom X dokładnie na MC | 0,000000000° |
|
||||||
|
| porphyry | podział kwadrantów po ekliptyce | 0,000000000° |
|
||||||
|
| vehlow | equal z Ascendentem w środku domu I | 0,000000000° |
|
||||||
|
| morinus | równik rzutowany wprost na ekliptykę | 0,000000000° |
|
||||||
|
| regiomontanus | podział równika, koła przez punkty N/S horyzontu | 0,000000000° |
|
||||||
|
| campanus | podział wertykału pierwszego | 0,000000000° |
|
||||||
|
| alcabitus | podział łuków dobowych po równiku | 0,000000000° |
|
||||||
|
| topocentric | ascendenty pod biegunami tan(P) = tan(φ)·k/3 | 0,000000000° |
|
||||||
|
|
||||||
|
Etap 2 — systemy **łuku dobowego**, jedyne z realną granicą dziedziny:
|
||||||
|
|
||||||
|
| System | Konstrukcja | Maks. odchylenie |
|
||||||
|
|---|---|---|
|
||||||
|
| placidus | trójdzielenie półłuków, iteracyjnie | 0,000001686° |
|
||||||
|
| koch | trójdzielenie CZASU od wschodu stopnia MC | 0,000000000° |
|
||||||
|
|
||||||
|
Placidus jest jedynym systemem bez wzoru zamkniętego: cusp jest zdefiniowany
|
||||||
|
warunkiem na samego siebie, więc szukamy go iteracyjnie do 1e-11°. Pozostałe
|
||||||
|
1,7e-6° to **próg zbieżności wyroczni**, nie nasz błąd — nasz cusp spełnia
|
||||||
|
definicję Placidusa z dokładnością 1e-12° (test `test_placidus_cusps_satisfy_
|
||||||
|
their_own_definition` sprawdza to bez swissepha).
|
||||||
|
|
||||||
|
Powyżej koła podbiegunowego oba **odmawiają liczenia** — i wyrocznia odmawia
|
||||||
|
dokładnie tych samych przypadków (5245 na 20 000 losowych, zero rozjazdów).
|
||||||
|
Warstwa aplikacyjna łapie tę odmowę i podstawia Porphyry'ego z **głośnym
|
||||||
|
komunikatem**, który idzie na ekran, w prompt do modelu i do PDF-a: po podmianie
|
||||||
|
kosmogram wygląda bezbłędnie, więc jest to jedyny sygnał dla astrologa.
|
||||||
|
|
||||||
|
### Topocentric — i czego nauczył
|
||||||
|
|
||||||
|
Topocentric był początkowo **wycofany** z powodu rozjazdu przy |φ| ≈ 89,9°.
|
||||||
|
Ta diagnoza była **błędna**, bo opierała się wyłącznie na zestawie brzegowym,
|
||||||
|
który próbkuje tylko wybrane szerokości. Przemiał losowy pokazał prawdziwą skalę:
|
||||||
|
błędy zaczynały się już od ~70° i sięgały 50% przypadków blisko biegunów —
|
||||||
|
**6,5% całości**, nie dwa przypadki brzegowe.
|
||||||
|
|
||||||
|
Przyczyną nie była jednak konstrukcja, tylko wybór gałęzi. Dwa koła wielkie
|
||||||
|
przecinają się w dwóch punktach antypodycznych i trzeba wskazać właściwy.
|
||||||
|
Kolejno zawiodły: „po której stronie MC", „w łuku kwadrantu", „wschodnia połowa
|
||||||
|
horyzontu" oraz śledzenie ciągłości krokami. Każda z nich rozstrzyga **lokalnie**,
|
||||||
|
a przy dużych szerokościach kolejność domów potrafi się odwrócić — przy φ = −79,55°
|
||||||
|
MC wypada na 306,8°, a dom 11 na 291,4°, czyli domy biegną wstecz. To jest poprawne
|
||||||
|
i wyrocznia zwraca dokładnie to samo.
|
||||||
|
|
||||||
|
Rozwiązaniem było **nie wybierać w ogóle**. Iloczyn wektorowy zenitu z biegunem
|
||||||
|
ekliptyki jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot;
|
||||||
|
dwuznaczność wprowadza dopiero `atan2`. Zostając w wektorach i kotwicząc rodzinę
|
||||||
|
raz — na MC górującym — dostajemy zgodność **co do zera na całej dziedzinie**,
|
||||||
|
bez iteracji i bez zawężania szerokości.
|
||||||
|
|
||||||
|
Morał praktyczny: zestaw brzegowy mówi, **czy** system się psuje; dopiero przemiał
|
||||||
|
losowy mówi **jak bardzo**. Wniosek o skali wyciągnięty z samych brzegów był tu
|
||||||
|
zaniżony o trzy rzędy wielkości.
|
||||||
|
|
||||||
|
Większe jądro efemeryd (de441) — **odłożone, „nice to have"**: rozszerza tylko
|
||||||
|
zakres dat, nie poprawia niczego w obecnym.
|
||||||
|
|
||||||
|
## Licencja
|
||||||
|
|
||||||
|
Swiss Ephemeris jest na AGPL i jest tu **wyłącznie wyrocznią testową** — nie wchodzi
|
||||||
|
do ścieżki produkcyjnej ani do zależności produktu. Izolacja z LOG-27 zostaje
|
||||||
|
nienaruszona.
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Dziedzina wejścia funkcji liczącej domy — klasy równoważności i wartości brzegowe.
|
||||||
|
|
||||||
|
Liczenie cuspów traktujemy jako FUNKCJĘ trzech parametrów: (RAMC, ε, φ). ε nie jest
|
||||||
|
niezależne — wynika z daty, więc realnymi parametrami są (data, φ) plus RAMC, który
|
||||||
|
zależy od czasu gwiazdowego i długości geograficznej.
|
||||||
|
|
||||||
|
Zakres dat programu: 370 p.n.e. – 2270 n.e. (rok astronomiczny -369 … 2270).
|
||||||
|
|
||||||
|
PUŁAPKA, o którą łatwo się potknąć: granica dziedziny dla Placidusa i Kocha —
|
||||||
|
koło podbiegunowe — NIE jest stałą 66,56°. Zależy od ε, a ε zmienia się z datą.
|
||||||
|
W zakresie programu przesuwa się o ~0,3°. Wartości brzegowe MUSZĄ więc być liczone
|
||||||
|
per data, inaczej testy brzegowe celują obok granicy.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# Zakres dat programu jako dni juliańskie (rok astronomiczny: 370 p.n.e. = -369).
|
||||||
|
# Wyznacza go jądro efemeryd de421, którego używa silnik. Większe jądro (de441,
|
||||||
|
# ~13 000 lat) jest DECYZJĄ ODŁOŻONĄ — świadomie „nice to have", nie „must":
|
||||||
|
# rozszerza wyłącznie zakres dat, a nie poprawność czegokolwiek w obecnym
|
||||||
|
# zakresie. Gdy kiedyś dojdzie, tu jest jedno miejsce do zmiany — i wtedy
|
||||||
|
# wartości brzegowe (ε, koło podbiegunowe) trzeba przeliczyć dla nowych krańców.
|
||||||
|
JD_MIN = 1_566_839.5 # -369-01-01
|
||||||
|
JD_MAX = 2_695_000.5 # ~2270-12-31
|
||||||
|
JD_J2000 = 2_451_545.0 # punkt odniesienia — środek dziedziny
|
||||||
|
|
||||||
|
# Systemy oparte na łuku dobowym: tracą definicję za kołem podbiegunowym.
|
||||||
|
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).
|
||||||
|
EPSILON_NUDGE = 0.01
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Case:
|
||||||
|
"""Pojedynczy zestaw danych wejściowych + skąd się wziął (do raportu)."""
|
||||||
|
ramc: float
|
||||||
|
lat: float
|
||||||
|
jd: float
|
||||||
|
label: str # "brzeg: równik" / "wnętrze" / "losowy" — grupowanie w raporcie
|
||||||
|
|
||||||
|
def key(self) -> str:
|
||||||
|
return f"ramc={self.ramc:.4f} lat={self.lat:.4f} jd={self.jd:.1f}"
|
||||||
|
|
||||||
|
|
||||||
|
def obliquity(jd: float) -> float:
|
||||||
|
"""ε dla daty — z NASZEJ implementacji, żeby granice liczyć tak samo jak silnik."""
|
||||||
|
from app.engine.houses import mean_obliquity
|
||||||
|
|
||||||
|
return mean_obliquity(jd)
|
||||||
|
|
||||||
|
|
||||||
|
def polar_circle(jd: float) -> float:
|
||||||
|
from app.engine.houses import polar_circle as pc
|
||||||
|
|
||||||
|
return pc(obliquity(jd))
|
||||||
|
|
||||||
|
|
||||||
|
def in_domain(system: str, lat: float, jd: float) -> bool:
|
||||||
|
"""Czy zestaw należy do dziedziny danego systemu.
|
||||||
|
|
||||||
|
Poza dziedziną są wyłącznie systemy łuku dobowego powyżej koła podbiegunowego
|
||||||
|
(i same bieguny, gdzie degeneruje się cała geometria)."""
|
||||||
|
if abs(lat) >= 90.0:
|
||||||
|
return False
|
||||||
|
if system in DIURNAL_ARC_SYSTEMS or system in NARROWER_THAN_ORACLE:
|
||||||
|
return abs(lat) < polar_circle(jd)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── wartości brzegowe ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _boundary_lats(jd: float) -> list[tuple[float, str]]:
|
||||||
|
"""Szerokości brzegowe DLA DANEJ DATY (koło podbiegunowe zależy od ε)."""
|
||||||
|
pc = polar_circle(jd)
|
||||||
|
out: list[tuple[float, str]] = [
|
||||||
|
(0.0, "brzeg: równik"),
|
||||||
|
(pc - EPSILON_NUDGE, "brzeg: tuż POD kołem podbieg."),
|
||||||
|
(pc + EPSILON_NUDGE, "brzeg: tuż ZA kołem podbieg."),
|
||||||
|
(-(pc - EPSILON_NUDGE), "brzeg: tuż pod kołem (płd.)"),
|
||||||
|
(-(pc + EPSILON_NUDGE), "brzeg: tuż za kołem (płd.)"),
|
||||||
|
(89.9, "brzeg: prawie biegun N"),
|
||||||
|
(-89.9, "brzeg: prawie biegun S"),
|
||||||
|
]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _boundary_ramcs() -> list[tuple[float, str]]:
|
||||||
|
"""RAMC na krańcach i w punktach kardynalnych — tam wzory mają osobliwości."""
|
||||||
|
return [
|
||||||
|
(0.0, "brzeg: RAMC 0°"),
|
||||||
|
(90.0, "brzeg: RAMC 90°"),
|
||||||
|
(180.0, "brzeg: RAMC 180°"),
|
||||||
|
(270.0, "brzeg: RAMC 270°"),
|
||||||
|
(359.99, "brzeg: RAMC ~360°"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _boundary_jds() -> list[tuple[float, str]]:
|
||||||
|
return [
|
||||||
|
(JD_MIN, "brzeg: początek zakresu dat"),
|
||||||
|
(JD_J2000, "środek zakresu (J2000)"),
|
||||||
|
(JD_MAX, "brzeg: koniec zakresu dat"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def boundary_cases() -> list[Case]:
|
||||||
|
"""Iloczyn wartości brzegowych — pełne pokrycie kombinacji granicznych."""
|
||||||
|
out: list[Case] = []
|
||||||
|
for jd, jl in _boundary_jds():
|
||||||
|
for lat, ll in _boundary_lats(jd):
|
||||||
|
for ramc, rl in _boundary_ramcs():
|
||||||
|
out.append(Case(ramc, lat, jd, f"{ll} | {rl} | {jl}"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def interior_cases(count: int, seed: int = 0) -> list[Case]:
|
||||||
|
"""Tyle samo przypadków z WNĘTRZA klas równoważności, ile brzegowych.
|
||||||
|
|
||||||
|
Deterministyczne przy danym ziarnie — ten sam build daje ten sam zestaw.
|
||||||
|
"""
|
||||||
|
rnd = random.Random(seed)
|
||||||
|
out: list[Case] = []
|
||||||
|
for _ in range(count):
|
||||||
|
jd = rnd.uniform(JD_MIN, JD_MAX)
|
||||||
|
pc = polar_circle(jd)
|
||||||
|
# wnętrze = z dala od granic, żeby nie dublować przypadków brzegowych
|
||||||
|
lat = rnd.uniform(-pc + 1.0, pc - 1.0)
|
||||||
|
out.append(Case(rnd.uniform(0.0, 360.0), lat, jd, "wnętrze"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def random_cases(count: int, seed: int) -> list[Case]:
|
||||||
|
"""Wielki losowy przemiał — CAŁA dziedzina, także za kołem podbiegunowym.
|
||||||
|
|
||||||
|
Ziarno jest wymagane i raportowane: losowy test, którego nie da się odtworzyć,
|
||||||
|
jest bezużyteczny, bo czerwień raz na tydzień nie daje się zdiagnozować.
|
||||||
|
"""
|
||||||
|
rnd = random.Random(seed)
|
||||||
|
return [
|
||||||
|
Case(rnd.uniform(0.0, 360.0), rnd.uniform(-89.9, 89.9),
|
||||||
|
rnd.uniform(JD_MIN, JD_MAX), "losowy")
|
||||||
|
for _ in range(count)
|
||||||
|
]
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Porównanie naszych domów z wyrocznią (Swiss Ephemeris) i raport zgodności.
|
||||||
|
|
||||||
|
ZASADA IZOLACJI: obu implementacjom podajemy TE SAME wejścia (RAMC, ε, φ) przez
|
||||||
|
`swe.houses_armc`. Gdybyśmy porównywali „nasz horoskop" z „horoskopem swissepha",
|
||||||
|
różnica w cuspie mogłaby wynikać z innego czasu gwiazdowego albo innego ε — czyli
|
||||||
|
z czegoś, co nie jest testowaną funkcją. Wtedy przy tysiącach przypadków utonęlibyśmy
|
||||||
|
w fałszywych alarmach. Czas gwiazdowy i ε testujemy OSOBNO (`compare_asc_mc`).
|
||||||
|
|
||||||
|
KRYTERIUM: nie procent zgodności, tylko liczba przypadków POWYŻEJ tolerancji plus
|
||||||
|
maksymalne odchylenie i MIEJSCE jego wystąpienia. Procent ukrywa kształt błędu:
|
||||||
|
„97%" nie odróżnia szumu zmiennoprzecinkowego od jednego rogu dziedziny, w którym
|
||||||
|
mylimy się o 30°.
|
||||||
|
|
||||||
|
LICENCJA: Swiss Ephemeris (AGPL) jest tu WYŁĄCZNIE wyrocznią testową. Nie wchodzi
|
||||||
|
do ścieżki produkcyjnej ani do zależności produktu — izolacja z LOG-27 zostaje.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import swisseph as swe
|
||||||
|
|
||||||
|
from domain import NARROWER_THAN_ORACLE, Case, in_domain, obliquity
|
||||||
|
|
||||||
|
# Litery systemów w Swiss Ephemeris.
|
||||||
|
SWE_CODE = {
|
||||||
|
"whole_sign": b"W", "equal": b"E",
|
||||||
|
"equal_mc": b"D",
|
||||||
|
"whole_sign_aries": b"N", "porphyry": b"O",
|
||||||
|
"placidus": b"P", "koch": b"K", "regiomontanus": b"R", "campanus": b"C",
|
||||||
|
"morinus": b"M", "alcabitus": b"B", "vehlow": b"V", "topocentric": b"T",
|
||||||
|
}
|
||||||
|
|
||||||
|
TOLERANCE_DEG = 1.0 / 3600.0 # 1″ — uzgodniony próg
|
||||||
|
|
||||||
|
|
||||||
|
def angular_diff(a: float, b: float) -> float:
|
||||||
|
"""Najmniejsza różnica kątowa [0,180] — 359,9° i 0,1° dzieli 0,2°, nie 359,8°."""
|
||||||
|
d = abs(a - b) % 360.0
|
||||||
|
return min(d, 360.0 - d)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Result:
|
||||||
|
system: str
|
||||||
|
checked: int = 0
|
||||||
|
over_tolerance: int = 0
|
||||||
|
skipped_out_of_domain: int = 0
|
||||||
|
max_dev: float = 0.0
|
||||||
|
max_where: str = ""
|
||||||
|
domain_mismatch: list[str] = field(default_factory=list)
|
||||||
|
deviations: list[float] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def passed(self) -> bool:
|
||||||
|
return self.over_tolerance == 0 and not self.domain_mismatch
|
||||||
|
|
||||||
|
|
||||||
|
def oracle_cusps(case: Case, eps: float, system: str) -> list[float] | None:
|
||||||
|
"""Cuspy z wyroczni albo None, gdy wyrocznia odmawia (poza dziedziną).
|
||||||
|
|
||||||
|
pyswisseph zgłasza wyjątek dla Placidusa/Kocha powyżej koła podbiegunowego —
|
||||||
|
to sygnał, że zestaw jest poza dziedziną, i sam w sobie jest użyteczny: pozwala
|
||||||
|
sprawdzić, czy NASZA detekcja granicy pokrywa się z wyrocznią."""
|
||||||
|
try:
|
||||||
|
cusps, _ = swe.houses_armc(case.ramc % 360.0, case.lat, eps, SWE_CODE[system])
|
||||||
|
return list(cusps)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compare_asc_mc(cases: list[Case]) -> Result:
|
||||||
|
"""Test funkcji WEJŚCIOWEJ: nasze Asc/MC vs wyrocznia, przy tym samym (RAMC, ε, φ).
|
||||||
|
|
||||||
|
Osobno, bo błąd tutaj kaskadowałby na wszystkie systemy i wyglądał jak błąd domów.
|
||||||
|
"""
|
||||||
|
from app.engine.houses import compute_asc, compute_mc
|
||||||
|
|
||||||
|
r = Result(system="asc/mc")
|
||||||
|
for c in cases:
|
||||||
|
eps = obliquity(c.jd)
|
||||||
|
try:
|
||||||
|
_, ascmc = swe.houses_armc(c.ramc % 360.0, c.lat, eps, b"O")
|
||||||
|
except Exception:
|
||||||
|
r.skipped_out_of_domain += 1
|
||||||
|
continue
|
||||||
|
for ours, theirs, what in (
|
||||||
|
(compute_asc(c.ramc, eps, c.lat), ascmc[0], "Asc"),
|
||||||
|
(compute_mc(c.ramc, eps), ascmc[1], "MC"),
|
||||||
|
):
|
||||||
|
d = angular_diff(ours, theirs)
|
||||||
|
r.checked += 1
|
||||||
|
r.deviations.append(d)
|
||||||
|
if d > r.max_dev:
|
||||||
|
r.max_dev, r.max_where = d, f"{what} @ {c.key()} [{c.label}]"
|
||||||
|
if d > TOLERANCE_DEG:
|
||||||
|
r.over_tolerance += 1
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def compare_system(system: str, cases: list[Case]) -> Result:
|
||||||
|
"""Porównanie jednego systemu domów z wyrocznią na zadanym zestawie przypadków."""
|
||||||
|
from app.engine.houses import cusps_for
|
||||||
|
|
||||||
|
r = Result(system=system)
|
||||||
|
for c in cases:
|
||||||
|
eps = obliquity(c.jd)
|
||||||
|
ours_in_domain = in_domain(system, c.lat, c.jd)
|
||||||
|
theirs = oracle_cusps(c, eps, system)
|
||||||
|
|
||||||
|
# Zgodność co do DZIEDZINY: jeśli wyrocznia odmawia, a my uważamy, że
|
||||||
|
# liczymy — nasza detekcja granicy jest zepsuta (albo odwrotnie).
|
||||||
|
# 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(
|
||||||
|
f"{c.key()} [{c.label}] — my:{'w dziedzinie' if ours_in_domain else 'poza'}, "
|
||||||
|
f"wyrocznia:{'odmawia' if theirs is None else 'liczy'}")
|
||||||
|
continue
|
||||||
|
if theirs is None or not ours_in_domain:
|
||||||
|
r.skipped_out_of_domain += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
ours = cusps_for(c.ramc, eps, c.lat, system)
|
||||||
|
except Exception as e: # nasz kod NIE MOŻE się wywalić
|
||||||
|
r.domain_mismatch.append(f"{c.key()} [{c.label}] — nasz kod rzucił: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i in range(12):
|
||||||
|
d = angular_diff(ours[i], theirs[i])
|
||||||
|
r.checked += 1
|
||||||
|
r.deviations.append(d)
|
||||||
|
if d > r.max_dev:
|
||||||
|
r.max_dev, r.max_where = d, f"dom {i + 1} @ {c.key()} [{c.label}]"
|
||||||
|
if d > TOLERANCE_DEG:
|
||||||
|
r.over_tolerance += 1
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def histogram(devs: list[float]) -> str:
|
||||||
|
"""Rozkład odchyleń — pokazuje KSZTAŁT zgodności, nie tylko sam maksimum."""
|
||||||
|
if not devs:
|
||||||
|
return " (brak danych)"
|
||||||
|
buckets = [
|
||||||
|
("= 0", lambda d: d == 0.0),
|
||||||
|
("< 1e-9°", lambda d: 0 < d < 1e-9),
|
||||||
|
("< 1e-6°", lambda d: 1e-9 <= d < 1e-6),
|
||||||
|
("< 1″", lambda d: 1e-6 <= d < TOLERANCE_DEG),
|
||||||
|
("≥ 1″ (BŁĄD)", lambda d: d >= TOLERANCE_DEG),
|
||||||
|
]
|
||||||
|
out = []
|
||||||
|
for name, pred in buckets:
|
||||||
|
n = sum(1 for d in devs if pred(d))
|
||||||
|
if n:
|
||||||
|
out.append(f" {name:14} {n:7} {'█' * min(40, 1 + n * 40 // len(devs))}")
|
||||||
|
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:
|
||||||
|
lines = ["", "=" * 78,
|
||||||
|
"ZGODNOŚĆ Z WYROCZNIĄ (Swiss Ephemeris) — domy astrologiczne",
|
||||||
|
f"tolerancja: {TOLERANCE_DEG:.8f}° (1″)" + (f" ziarno: {seed}" if seed is not None else ""),
|
||||||
|
"=" * 78,
|
||||||
|
f"{'SYSTEM':<18}{'SPRAWDZONYCH':>13}{'>TOL':>7}{'POZA DZIEDZ.':>14}{'MAX ODCH.':>14} WYNIK"]
|
||||||
|
for r in results:
|
||||||
|
lines.append(f"{r.system:<18}{r.checked:>13}{r.over_tolerance:>7}"
|
||||||
|
f"{r.skipped_out_of_domain:>14}{r.max_dev:>14.9f} "
|
||||||
|
f"{'OK' if r.passed else 'BŁĄD'}")
|
||||||
|
for r in results:
|
||||||
|
if r.max_dev > 0 or not r.passed:
|
||||||
|
lines += ["", f" [{r.system}] największe odchylenie: {r.max_dev:.9f}° — {r.max_where}"]
|
||||||
|
if r.domain_mismatch:
|
||||||
|
lines.append(f" [{r.system}] NIEZGODNOŚĆ DZIEDZINY ({len(r.domain_mismatch)}):")
|
||||||
|
lines += [f" {m}" for m in r.domain_mismatch[:5]]
|
||||||
|
if len(r.domain_mismatch) > 5:
|
||||||
|
lines.append(f" … i {len(r.domain_mismatch) - 5} więcej")
|
||||||
|
lines += ["", " Rozkład odchyleń (wszystkie systemy):", histogram(
|
||||||
|
[d for r in results for d in r.deviations]), "=" * 78]
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Uruchomienie porównania z wyrocznią. Punkt wejścia dla CI i dla Joba w klastrze.
|
||||||
|
|
||||||
|
Dwa tryby:
|
||||||
|
--mode build (domyślny) zestaw BRZEGOWY + tyle samo przypadków z wnętrza.
|
||||||
|
Szybki, deterministyczny, BLOKUJE build przy błędzie.
|
||||||
|
--mode sweep wielki losowy przemiał, o rząd wielkości większy.
|
||||||
|
Do Joba w klastrze; ziarno raportowane, więc każdy
|
||||||
|
błąd da się odtworzyć komendą z raportu.
|
||||||
|
|
||||||
|
Kod wyjścia: 0 = zgodność w granicach tolerancji, 1 = przekroczenia albo
|
||||||
|
niezgodność dziedziny. Dzięki temu CI może na tym polegać bez parsowania tekstu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Ścieżka do warstwy logicznej: w CI montujemy ją do kontenera swissepha.
|
||||||
|
sys.path.insert(0, os.environ.get("LOGIC_PATH", "/logic"))
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from domain import boundary_cases, interior_cases, random_cases # noqa: E402
|
||||||
|
from harness import ( # noqa: E402
|
||||||
|
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) —
|
||||||
|
# dopisanie nazwy tutaj wystarcza, żeby weszła do każdego builda.
|
||||||
|
SYSTEMS = ["whole_sign", "whole_sign_aries", "equal", "equal_mc", "porphyry", "vehlow", "morinus",
|
||||||
|
"regiomontanus", "campanus", "alcabitus", "topocentric", "placidus", "koch"]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser(description="Zgodność domów z wyrocznią (Swiss Ephemeris)")
|
||||||
|
p.add_argument("--mode", choices=("build", "sweep"), default="build")
|
||||||
|
p.add_argument("--seed", type=int, default=0, help="ziarno losowania (do odtworzenia błędu)")
|
||||||
|
p.add_argument("--count", type=int, default=20000, help="liczba przypadków w trybie sweep")
|
||||||
|
p.add_argument("--systems", default=",".join(SYSTEMS))
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
systems = [s.strip() for s in args.systems.split(",") if s.strip()]
|
||||||
|
unknown = [s for s in systems if s not in SWE_CODE]
|
||||||
|
if unknown:
|
||||||
|
print(f"Nieznane systemy: {unknown}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if args.mode == "build":
|
||||||
|
boundary = boundary_cases()
|
||||||
|
# Tyle samo z wnętrza, ile brzegowych — brzegi łapią osobliwości,
|
||||||
|
# wnętrze pilnuje, że nie zepsuliśmy zwykłych przypadków.
|
||||||
|
cases = boundary + interior_cases(len(boundary), seed=args.seed)
|
||||||
|
seed_note = args.seed
|
||||||
|
else:
|
||||||
|
cases = random_cases(args.count, seed=args.seed)
|
||||||
|
seed_note = args.seed
|
||||||
|
|
||||||
|
results = [compare_asc_mc(cases)]
|
||||||
|
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(format_report(results, seed=seed_note))
|
||||||
|
|
||||||
|
failed = [r for r in results if not r.passed]
|
||||||
|
if failed:
|
||||||
|
print("\nBŁĄD: " + ", ".join(r.system for r in failed))
|
||||||
|
print(f"Odtworzenie: python run.py --mode {args.mode} --seed {args.seed}"
|
||||||
|
+ (f" --count {args.count}" if args.mode == "sweep" else ""))
|
||||||
|
return 1
|
||||||
|
print("\nWszystkie systemy zgodne z wyrocznią w granicach tolerancji.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user