Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a7f7e1b13 | |||
| a833965909 | |||
| baf4e0e38a | |||
| 8b6ecc727d | |||
| 21a00b0000 |
@@ -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,6 +138,37 @@ jobs:
|
|||||||
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
|
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
|
||||||
|
|
||||||
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
|
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
|
||||||
|
|
||||||
|
# /houses — kontrakt parzystosci (LOG-28) po stronie DOMOW. Sprawdzamy
|
||||||
|
# nie tylko, ze liczy, ale i ze ODMAWIA tam, gdzie system nie istnieje:
|
||||||
|
# ciche podstawienie innego systemu byloby niewykrywalne dla wolajacego.
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.main import _HOUSE_CODES, HousesRequest, houses
|
||||||
|
|
||||||
|
WHEN = "1984-04-30T09:20:00Z"
|
||||||
|
h = houses(HousesRequest(when_utc=WHEN, lat=50.0647, lon=19.9450, system="campanus"))
|
||||||
|
assert h["engine"] == "swisseph" and h["system"] == "campanus"
|
||||||
|
assert len(h["cusps"]) == 12, h["cusps"]
|
||||||
|
assert [c["house"] for c in h["cusps"]] == list(range(1, 13))
|
||||||
|
assert all(0.0 <= c["longitude"] < 360.0 for c in h["cusps"])
|
||||||
|
assert {"Asc", "MC", "ARMC"} <= set(h["angles"]), h["angles"]
|
||||||
|
|
||||||
|
# kazdy ogloszony system musi dac 12 cuspow tam, gdzie ma definicje
|
||||||
|
for name in _HOUSE_CODES:
|
||||||
|
out = houses(HousesRequest(when_utc=WHEN, lat=50.0647, lon=19.9450, system=name))
|
||||||
|
assert len(out["cusps"]) == 12, name
|
||||||
|
print(f"/houses: {len(_HOUSE_CODES)} systemow OK")
|
||||||
|
|
||||||
|
for bad, why in ((dict(lat=69.65, lon=18.96, system="placidus"), "Tromso/placidus"),
|
||||||
|
(dict(lat=50.0, lon=19.0, system="nie-ma-takiego"), "nieznany system")):
|
||||||
|
try:
|
||||||
|
houses(HousesRequest(when_utc=WHEN, **bad))
|
||||||
|
raise AssertionError(f"{why}: mialo byc 422, a przeszlo")
|
||||||
|
except HTTPException as e:
|
||||||
|
assert e.status_code == 422, (why, e.status_code)
|
||||||
|
print("/houses: odmowy poza dziedzina OK")
|
||||||
|
|
||||||
print("SMOKE OK")
|
print("SMOKE OK")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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.
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -60,7 +60,15 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
|
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
|
||||||
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
|
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
|
||||||
# z surowych długości/szerokości silnika.
|
# z surowych długości/szerokości silnika.
|
||||||
|
# ε PRAWDZIWE — to samo, którym liczymy domy, bo pozycje ze Skyfielda są
|
||||||
|
# POZORNE (uwzględniają nutację). Silnik bez sidereal() nutacji nie poda;
|
||||||
|
# wtedy zostaje średnie, z jawnym oznaczeniem w wyniku.
|
||||||
|
if hasattr(engine, "sidereal"):
|
||||||
|
eps = engine.sidereal(moment)[1]
|
||||||
|
result["obliquity_kind"] = "true"
|
||||||
|
else:
|
||||||
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
||||||
|
result["obliquity_kind"] = "mean"
|
||||||
result["obliquity"] = round(eps, 6)
|
result["obliquity"] = round(eps, 6)
|
||||||
for pdict, obj in zip(result["positions"], positions):
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
dec = declination(obj.longitude, obj.latitude, eps)
|
dec = declination(obj.longitude, obj.latitude, eps)
|
||||||
|
|||||||
@@ -321,6 +321,22 @@ def _cusps_topocentric(ramc: float, eps: float, lat: float,
|
|||||||
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik
|
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik
|
||||||
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
|
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
|
||||||
konstrukcji „ascendent pod biegunem"."""
|
konstrukcji „ascendent pod biegunem"."""
|
||||||
|
# Cuspy topocentrica są poprawne na CAŁEJ dziedzinie (zgodne z wyrocznią co do
|
||||||
|
# zera), ale powyżej koła podbiegunowego przestają DZIELIĆ OKRĄG: domy nachodzą
|
||||||
|
# na siebie, bo cusp VII (= I + 180°) wypada przed cuspem VI. Przypisanie planety
|
||||||
|
# do domu traci wtedy sens — co potwierdza sama wyrocznia, której swe_house_pos
|
||||||
|
# przeczy tam własnym cuspom (100% zgodności do 62°, 83,9% przy 66°, ok. 50%
|
||||||
|
# przy 72°). Odmawiamy, zamiast zwracać liczbę bez znaczenia.
|
||||||
|
#
|
||||||
|
# To INNY rodzaj granicy niż u Placidusa i Kocha: tam nie istnieją same cuspy,
|
||||||
|
# tu istnieją, tylko nie tworzą podziału. Próg jest wyprowadzony z warunku
|
||||||
|
# „dwanaście cuspów sumuje się do 360°", nie dobrany pod wynik testu — i wypada
|
||||||
|
# na kole podbiegunowym (zmierzone: 100% podziałów do 65°, 78% w pasie 66-67°).
|
||||||
|
if abs(lat) >= polar_circle(eps):
|
||||||
|
raise HouseSystemUndefined(
|
||||||
|
f"φ={lat:.4f}° powyżej koła podbiegunowego ({polar_circle(eps):.4f}° dla "
|
||||||
|
f"ε={eps:.4f}°): cuspy topocentryczne przestają dzielić okrąg, "
|
||||||
|
f"domy nachodzą na siebie")
|
||||||
er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
|
er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
|
||||||
epole = _ecliptic_pole(er)
|
epole = _ecliptic_pole(er)
|
||||||
# Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
|
# Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
|
||||||
@@ -534,14 +550,38 @@ def cusps_detailed(ramc: float, eps: float, lat: float, system: str) -> CuspSet:
|
|||||||
FALLBACK_SYSTEM, system, str(e))
|
FALLBACK_SYSTEM, system, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _runs_forward(cusp_list: list[float]) -> bool:
|
||||||
|
"""Czy domy biegną w stronę rosnących długości ekliptycznych.
|
||||||
|
|
||||||
|
Zwykle tak — ale NIE ZAWSZE. Przy dużych szerokościach systemy dzielące koła
|
||||||
|
wielkie (regiomontanus, campanus, topocentric) mają kolejność ODWRÓCONĄ:
|
||||||
|
przy φ = −84,3° cusp domu I wypada na 174,2°, a domu II na 165,3°. To nie
|
||||||
|
jest błąd — wyrocznia zwraca dokładnie te same wartości.
|
||||||
|
|
||||||
|
Rozstrzygamy sumą przeskoków „do przodu": dwanaście cuspów dzieli okrąg, więc
|
||||||
|
idąc we WŁAŚCIWĄ stronę zsumują się do 360°. Idąc pod prąd każdy przeskok
|
||||||
|
obchodzi koło dookoła i suma wychodzi 11 × 360° = 3960°."""
|
||||||
|
total = sum((cusp_list[(i + 1) % 12] - cusp_list[i]) % 360.0 for i in range(12))
|
||||||
|
return abs(total - 360.0) < abs(total - 3960.0)
|
||||||
|
|
||||||
|
|
||||||
def assign_house(lon: float, cusp_list: list[float]) -> int:
|
def assign_house(lon: float, cusp_list: list[float]) -> int:
|
||||||
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
|
"""Numer domu (1..12), w którym leży dana długość ekliptyczna.
|
||||||
|
|
||||||
|
Kierunek liczenia bierzemy z samych cuspów. Zaszycie „zawsze do przodu"
|
||||||
|
dawało przy |φ| powyżej koła podbiegunowego złe domy dla regiomontanusa,
|
||||||
|
campanusa i topocentrica — mimo cuspów zgodnych z wyrocznią co do zera.
|
||||||
|
Błąd był CICHY: wykres wyglądał poprawnie, tylko planety siedziały gdzie
|
||||||
|
indziej. Sprawdzane wobec swe_house_pos (tests/oracle)."""
|
||||||
lon = norm360(lon)
|
lon = norm360(lon)
|
||||||
|
forward = _runs_forward(cusp_list)
|
||||||
for i in range(12):
|
for i in range(12):
|
||||||
start = cusp_list[i]
|
start = cusp_list[i]
|
||||||
end = cusp_list[(i + 1) % 12]
|
end = cusp_list[(i + 1) % 12]
|
||||||
span = (end - start) % 360.0
|
if forward:
|
||||||
offset = (lon - start) % 360.0
|
span, offset = (end - start) % 360.0, (lon - start) % 360.0
|
||||||
|
else:
|
||||||
|
span, offset = (start - end) % 360.0, (start - lon) % 360.0
|
||||||
if offset < span:
|
if offset < span:
|
||||||
return i + 1
|
return i + 1
|
||||||
return 12
|
return 12
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ def test_topocentric_cusps_stay_in_order_between_mc_and_ascendant():
|
|||||||
def d(a, b):
|
def d(a, b):
|
||||||
return H.norm360(b - a)
|
return H.norm360(b - a)
|
||||||
|
|
||||||
for lat in (-89.9, -79.55, -67.18, 0.0, 50.06, 73.69, 80.66, 89.9):
|
for lat in (-66.0, -50.0, -20.0, 0.0, 20.0, 50.06, 66.0):
|
||||||
c = H.cusps_for(129.2387, 23.4393, lat, "topocentric")
|
c = H.cusps_for(129.2387, 23.4393, lat, "topocentric")
|
||||||
mc, c11, c12, asc = c[9], c[10], c[11], c[0]
|
mc, c11, c12, asc = c[9], c[10], c[11], c[0]
|
||||||
forward = d(mc, c11) + d(c11, c12) + d(c12, asc)
|
forward = d(mc, c11) + d(c11, c12) + d(c12, asc)
|
||||||
@@ -223,13 +223,23 @@ def test_topocentric_cusps_stay_in_order_between_mc_and_ascendant():
|
|||||||
f"φ={lat}: MC={mc:.3f} 11={c11:.3f} 12={c12:.3f} Asc={asc:.3f} nie po kolei"
|
f"φ={lat}: MC={mc:.3f} 11={c11:.3f} 12={c12:.3f} Asc={asc:.3f} nie po kolei"
|
||||||
|
|
||||||
|
|
||||||
def test_topocentric_needs_no_domain_limit():
|
def test_topocentric_refuses_where_its_houses_stop_partitioning_the_circle():
|
||||||
"""W przeciwieństwie do Placidusa/Kocha topocentric liczy się WSZĘDZIE —
|
"""Topocentric ma granicę INNEGO rodzaju niż Placidus i Koch.
|
||||||
nie ma tu granicy dziedziny do wyprowadzenia, więc nie wolno jej udawać."""
|
|
||||||
for lat in (0.0, 66.5, 69.65, 89.9, -89.9):
|
Same cuspy są poprawne wszędzie (zgodne z wyrocznią co do zera), ale powyżej
|
||||||
|
koła podbiegunowego przestają dzielić okrąg — cusp VII (= I + 180°) wypada
|
||||||
|
przed cuspem VI i domy nachodzą na siebie. Przypisanie planety do domu traci
|
||||||
|
wtedy sens, więc odmawiamy zamiast zwracać liczbę bez znaczenia.
|
||||||
|
|
||||||
|
Próg jest WYPROWADZONY z warunku „dwanaście cuspów sumuje się do 360°",
|
||||||
|
a nie dobrany pod wynik testu — i wypada na kole podbiegunowym."""
|
||||||
|
for lat in (0.0, 50.06, 66.0):
|
||||||
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
|
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
|
||||||
|
for lat in (69.65, 89.9, -89.9):
|
||||||
|
with pytest.raises(H.HouseSystemUndefined):
|
||||||
|
H.cusps_for(100.0, 23.4393, lat, "topocentric")
|
||||||
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
|
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
|
||||||
assert not cs.is_fallback and cs.notice is None
|
assert cs.is_fallback and cs.notice and "topocentric" in cs.notice
|
||||||
|
|
||||||
|
|
||||||
def test_whole_sign_aries_ignores_the_ascendant():
|
def test_whole_sign_aries_ignores_the_ascendant():
|
||||||
@@ -249,3 +259,90 @@ def test_equal_mc_starts_the_tenth_house_exactly_on_the_midheaven():
|
|||||||
assert c[0] == pytest.approx(H.norm360(mc + 90.0), abs=1e-9)
|
assert c[0] == pytest.approx(H.norm360(mc + 90.0), abs=1e-9)
|
||||||
for i in range(12): # nadal równe 30°
|
for i in range(12): # nadal równe 30°
|
||||||
assert H.norm360(c[(i + 1) % 12] - c[i]) == pytest.approx(30.0, abs=1e-9)
|
assert H.norm360(c[(i + 1) % 12] - c[i]) == pytest.approx(30.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
# ── nachylenie ekliptyki: prawdziwe, nie średnie ─────────────────────────
|
||||||
|
|
||||||
|
def test_sidereal_returns_true_obliquity_not_mean():
|
||||||
|
"""RAMC liczymy z GAST — czasu gwiazdowego POZORNEGO, mierzonego od
|
||||||
|
równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej samej równonocy ma ε
|
||||||
|
Z NUTACJĄ. Sparowanie GAST z ε średnim mieszało dwa układy odniesienia
|
||||||
|
i przesuwało cuspy domów o ~3″.
|
||||||
|
|
||||||
|
Framework wyroczni tego NIE wykryje: z założenia podaje to samo ε obu
|
||||||
|
stronom, żeby izolować samą funkcję domów. Dlatego sprawdzian jest tutaj,
|
||||||
|
ze Skyfieldem jako niezależnym autorytetem."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from skyfield.api import load
|
||||||
|
from skyfield.nutationlib import mean_obliquity
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.skyfield_engine import SkyfieldEngine, true_obliquity
|
||||||
|
|
||||||
|
when = datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc)
|
||||||
|
t = load.timescale().from_datetime(when)
|
||||||
|
_, eps = SkyfieldEngine().sidereal(ChartMoment(when_utc=when, lat=50.06, lon=19.94))
|
||||||
|
|
||||||
|
assert eps == pytest.approx(true_obliquity(t), abs=1e-12)
|
||||||
|
eps_mean = float(mean_obliquity(t.tdb)) / 3600.0
|
||||||
|
assert eps != pytest.approx(eps_mean, abs=1e-9), "ε jest średnie — nutacja zgubiona"
|
||||||
|
# nutacja w nachyleniu nie przekracza ~9,2″; większa różnica to nie nutacja
|
||||||
|
assert abs(eps - eps_mean) * 3600.0 < 9.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_true_obliquity_stays_in_a_sane_band_across_the_date_range():
|
||||||
|
"""ε maleje ~0,013°/wiek. Na całym zakresie programu (370 p.n.e. – 2270 n.e.)
|
||||||
|
musi zostać w widełkach 23,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)
|
||||||
@@ -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)
|
||||||
@@ -18,25 +18,32 @@ from pathlib import Path
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app import accounts as accounts_store
|
||||||
from app import chartwheel as chartwheel_mod
|
from app import chartwheel as chartwheel_mod
|
||||||
|
from app import features
|
||||||
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
||||||
|
|
||||||
from app import geocode, security
|
from app import geocode, security
|
||||||
from app.clients.logic_client import LogicClient
|
from app.clients.logic_client import LogicClient
|
||||||
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa prezentacji")
|
# Automatyczna dokumentacja WYŁĄCZONA. /docs, /redoc i /openapi.json wypisują
|
||||||
|
# KOMPLET tras aplikacji — czyli spis wszystkich funkcji, łącznie z tymi, o których
|
||||||
|
# konto ograniczone nie ma prawa wiedzieć (PRE-27). Ochrona zakładek nic by nie dała,
|
||||||
|
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
|
||||||
|
app = FastAPI(title="astrololo · warstwa prezentacji",
|
||||||
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
logic = LogicClient()
|
logic = LogicClient()
|
||||||
security.install(app) # logowanie + limit żądań (LOG-32)
|
security.install(app) # logowanie + limit żądań (LOG-32)
|
||||||
|
|
||||||
|
|
||||||
# ── cache-busting statyki (PRE-26) ─────────────────────────────────────────
|
# ── cache-busting statyki (PRE-27) ─────────────────────────────────────────
|
||||||
# Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache).
|
# Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache).
|
||||||
# Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL →
|
# Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL →
|
||||||
# przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej).
|
# przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej).
|
||||||
@@ -64,6 +71,45 @@ templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
|
|||||||
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
|
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
|
||||||
|
|
||||||
|
|
||||||
|
def _perms(request: Request) -> frozenset[str]:
|
||||||
|
"""Uprawnienia zalogowanego konta — pusty zbiór, gdy nie wiadomo kto to."""
|
||||||
|
who = getattr(request.state, "principal", None)
|
||||||
|
return who.permissions if who is not None else frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
# Szablony pytają o uprawnienia PRZEZ TE DWIE FUNKCJE, nigdy o konto wprost.
|
||||||
|
# Dzięki temu nie da się w szablonie napisać warunku na login — a to jest
|
||||||
|
# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym
|
||||||
|
# nikt już nie pamięta.
|
||||||
|
templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request))
|
||||||
|
templates.env.globals["can"] = lambda request, key: key in _perms(request)
|
||||||
|
|
||||||
|
|
||||||
|
def _limit_options(request: Request, opts: dict) -> dict:
|
||||||
|
"""Ścina opcje, których konto nie ma prawa użyć — PO STRONIE SERWERA.
|
||||||
|
|
||||||
|
Ukrycie pola w formularzu chroni tylko przed przypadkiem. Kto zna nazwy pól,
|
||||||
|
wyśle je wprost — a wtedy policzyłby funkcję, której mu nie przyznano, i to
|
||||||
|
jeszcze zobaczyłby ją w wyniku. Granicą jest ta funkcja, nie szablon.
|
||||||
|
|
||||||
|
Wartości zastępcze są DOMYŚLNE, nie puste: konto ograniczone ma dostać
|
||||||
|
działający horoskop w podstawowej postaci, a nie komunikat o braku czegoś.
|
||||||
|
"""
|
||||||
|
perms = _perms(request)
|
||||||
|
if "advanced_calc" not in perms:
|
||||||
|
opts.update(stations=False, tables=False, aspect_minor=False, zodiac="tropical")
|
||||||
|
if "houses_compare" not in perms:
|
||||||
|
opts.update(house_system="whole_sign", house_systems=[],
|
||||||
|
wheel_orientation=chartwheel_mod.ASC_LEFT)
|
||||||
|
return opts
|
||||||
|
|
||||||
|
|
||||||
|
def _landing(request: Request) -> str | None:
|
||||||
|
"""Pierwszy ekran dostępny dla konta — dokąd odesłać z korzenia."""
|
||||||
|
allowed = features.screens_for(_perms(request))
|
||||||
|
return allowed[0].href if allowed else None
|
||||||
|
|
||||||
|
|
||||||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||||
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
|
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
|
||||||
|
|
||||||
@@ -108,6 +154,16 @@ def _logic_error(e: Exception) -> str:
|
|||||||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def chart_form(request: Request):
|
def chart_form(request: Request):
|
||||||
|
# Korzeń jest dostępny dla każdego zalogowanego, ale ekran „Horoskop" już nie.
|
||||||
|
# Bez tego konto bez uprawnienia do horoskopu dostawałoby 404 pod adresem
|
||||||
|
# głównym — czyli wyglądałoby to na zepsuty program, a nie na węższy zestaw.
|
||||||
|
if "chart" not in _perms(request):
|
||||||
|
target = _landing(request)
|
||||||
|
if target:
|
||||||
|
return RedirectResponse(target, status_code=303)
|
||||||
|
return HTMLResponse("<h1>Brak przypisanych funkcji</h1>"
|
||||||
|
"<p>Skontaktuj się z osobą, która zakładała konto.</p>",
|
||||||
|
status_code=200)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "chart.html",
|
request, "chart.html",
|
||||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
@@ -140,22 +196,27 @@ def chart_compute(
|
|||||||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||||||
"stations": stations, "zodiac": zodiac, "tables": tables,
|
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||||||
"wheel_orientation": wheel_orientation}
|
"wheel_orientation": wheel_orientation}
|
||||||
|
form = _limit_options(request, form)
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
ctx["result"] = logic.positions(
|
ctx["result"] = logic.positions(
|
||||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
house_system=house_system, house_systems=house_systems,
|
house_system=form["house_system"], house_systems=form["house_systems"],
|
||||||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||||||
aspect_minor=aspect_minor,
|
aspect_minor=form["aspect_minor"],
|
||||||
stations=stations, zodiac=zodiac, tables=tables,
|
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||||||
)
|
)
|
||||||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation)
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||||||
|
orientation=form["wheel_orientation"])
|
||||||
|
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
|
||||||
|
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
|
||||||
|
if "extra_charts" in _perms(request):
|
||||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -206,22 +267,25 @@ def compile_build(
|
|||||||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||||||
"stations": stations, "zodiac": zodiac, "tables": tables,
|
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||||||
"wheel_orientation": wheel_orientation}
|
"wheel_orientation": wheel_orientation}
|
||||||
|
form = _limit_options(request, form)
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
ctx["result"] = logic.positions(
|
ctx["result"] = logic.positions(
|
||||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
house_system=house_system, house_systems=house_systems,
|
house_system=form["house_system"], house_systems=form["house_systems"],
|
||||||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||||||
aspect_minor=aspect_minor,
|
aspect_minor=form["aspect_minor"],
|
||||||
stations=stations, zodiac=zodiac, tables=tables,
|
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||||||
)
|
)
|
||||||
from app import chartwheel
|
from app import chartwheel
|
||||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation)
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||||||
|
orientation=form["wheel_orientation"])
|
||||||
|
if "extra_charts" in _perms(request):
|
||||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -608,6 +672,74 @@ def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00")
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Konta i uprawnienia (PRE-27) ----------------
|
||||||
|
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
|
||||||
|
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
|
||||||
|
|
||||||
|
def _accounts_context(request: Request, error: str = "", done: str = "") -> dict:
|
||||||
|
# Problem z magazynem NIE MOŻE dawać gołego 500: to jedyny ekran, z którego
|
||||||
|
# administrator może go naprawić, więc musi na nim przeczytać, co i gdzie
|
||||||
|
# jest nie tak.
|
||||||
|
try:
|
||||||
|
users = accounts_store.all_users()
|
||||||
|
except accounts_store.AccountsUnavailable as e:
|
||||||
|
users, error = {}, str(e)
|
||||||
|
return {
|
||||||
|
"users": users,
|
||||||
|
"catalog": features.ALL,
|
||||||
|
"screens": features.SCREENS,
|
||||||
|
"extras": features.EXTRAS,
|
||||||
|
"admin_login": security.app_user(),
|
||||||
|
"store_path": accounts_store.store_path(),
|
||||||
|
"error": error, "done": done,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/accounts", response_class=HTMLResponse)
|
||||||
|
def accounts_view(request: Request, error: str = "", done: str = ""):
|
||||||
|
return templates.TemplateResponse(request, "accounts.html",
|
||||||
|
_accounts_context(request, error, done))
|
||||||
|
|
||||||
|
|
||||||
|
def _accounts_redirect(error: str = "", done: str = "") -> RedirectResponse:
|
||||||
|
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
||||||
|
powtórzyć zakładania ani kasowania konta."""
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||||||
|
return RedirectResponse(f"/accounts{'?' + q if q else ''}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/accounts/create")
|
||||||
|
def accounts_create(request: Request, login: str = Form(""), password: str = Form(""),
|
||||||
|
note: str = Form(""), granted: list[str] = Form([])):
|
||||||
|
try:
|
||||||
|
accounts_store.create(login, password, granted, note)
|
||||||
|
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||||||
|
return _accounts_redirect(error=str(e))
|
||||||
|
return _accounts_redirect(done=f"Założono konto „{login.strip()}”.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/accounts/update")
|
||||||
|
def accounts_update(request: Request, login: str = Form(...), password: str = Form(""),
|
||||||
|
note: str = Form(""), granted: list[str] = Form([])):
|
||||||
|
try:
|
||||||
|
accounts_store.update(login, granted=granted, password=password, note=note)
|
||||||
|
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||||||
|
return _accounts_redirect(error=str(e))
|
||||||
|
changed = "uprawnienia i hasło" if password.strip() else "uprawnienia"
|
||||||
|
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/accounts/delete")
|
||||||
|
def accounts_delete(request: Request, login: str = Form(...)):
|
||||||
|
try:
|
||||||
|
accounts_store.delete(login)
|
||||||
|
except (ValueError, accounts_store.AccountsUnavailable) as e:
|
||||||
|
return _accounts_redirect(error=str(e))
|
||||||
|
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {"status": "ok", "layer": "presentation"}
|
return {"status": "ok", "layer": "presentation"}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import os
|
|||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
@@ -131,12 +132,36 @@ def _is_public(path: str) -> bool:
|
|||||||
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
|
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
def authenticate(header: str | None) -> str | None:
|
@dataclass(frozen=True)
|
||||||
"""Nagłówek Basic → LOGIN zalogowanej osoby albo None.
|
class Principal:
|
||||||
|
"""Kto się zalogował i co wolno mu zobaczyć."""
|
||||||
|
|
||||||
Zwracamy login (nie bool), bo dziennik audytowy musi wiedzieć KTO — to sedno
|
login: str
|
||||||
PRE-17. Nieznany login i złe hasło traktujemy tak samo (bez podpowiadania,
|
permissions: frozenset[str]
|
||||||
które konto istnieje)."""
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_permissions() -> frozenset[str]:
|
||||||
|
"""Administrator widzi wszystko — z definicji, bez wpisywania listy ręcznie.
|
||||||
|
|
||||||
|
Liczone z katalogu, więc dopisanie nowej funkcji NIE wymaga pamiętania
|
||||||
|
o koncie administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja
|
||||||
|
byłaby niewidoczna dla jedynej osoby, która ma ją komu nadać."""
|
||||||
|
from app import features
|
||||||
|
|
||||||
|
return frozenset(features.GRANTABLE) | {features.ADMIN}
|
||||||
|
|
||||||
|
|
||||||
|
def principal(header: str | None) -> Principal | None:
|
||||||
|
"""Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują.
|
||||||
|
|
||||||
|
Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne
|
||||||
|
uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej
|
||||||
|
założenie w pliku konta o loginie administratora przesłoniłoby konto
|
||||||
|
administracyjne i odebrało mu uprawnienia.
|
||||||
|
|
||||||
|
Nieznany login i złe hasło traktujemy tak samo: odmowa nie mówi, które konto
|
||||||
|
istnieje."""
|
||||||
if not header or not header.lower().startswith("basic "):
|
if not header or not header.lower().startswith("basic "):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -144,10 +169,26 @@ def authenticate(header: str | None) -> str | None:
|
|||||||
user, _, password = raw.partition(":")
|
user, _, password = raw.partition(":")
|
||||||
except (binascii.Error, UnicodeDecodeError, IndexError):
|
except (binascii.Error, UnicodeDecodeError, IndexError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
spec = accounts().get(user)
|
spec = accounts().get(user)
|
||||||
if spec is None:
|
if spec is not None:
|
||||||
|
return (Principal(user, _admin_permissions(), True)
|
||||||
|
if _secret_matches(spec, password) else None)
|
||||||
|
|
||||||
|
from app import accounts as store
|
||||||
|
|
||||||
|
managed = store.secret_of(user)
|
||||||
|
if managed is None:
|
||||||
return None
|
return None
|
||||||
return user if _secret_matches(spec, password) else None
|
if not _secret_matches(managed, password):
|
||||||
|
return None
|
||||||
|
return Principal(user, store.permissions_of(user), False)
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate(header: str | None) -> str | None:
|
||||||
|
"""Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz."""
|
||||||
|
who = principal(header)
|
||||||
|
return who.login if who else None
|
||||||
|
|
||||||
|
|
||||||
def client_ip(request: Request) -> str:
|
def client_ip(request: Request) -> str:
|
||||||
@@ -264,16 +305,32 @@ def install(app) -> None:
|
|||||||
status_code=429, headers={"Retry-After": "60"},
|
status_code=429, headers={"Retry-After": "60"},
|
||||||
)
|
)
|
||||||
|
|
||||||
user = ""
|
from app import features
|
||||||
|
|
||||||
|
user, who = "", None
|
||||||
if auth_enabled():
|
if auth_enabled():
|
||||||
user = authenticate(request.headers.get("authorization")) or ""
|
who = principal(request.headers.get("authorization"))
|
||||||
if not user:
|
if who is None:
|
||||||
_audit(request, "-", 401, (time.monotonic() - started) * 1000)
|
_audit(request, "-", 401, (time.monotonic() - started) * 1000)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
"<h1>401 — wymagane logowanie</h1>", status_code=401,
|
"<h1>401 — wymagane logowanie</h1>", status_code=401,
|
||||||
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
|
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
|
||||||
)
|
)
|
||||||
|
user = who.login
|
||||||
|
else:
|
||||||
|
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
|
||||||
|
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
|
||||||
|
who = Principal("", _admin_permissions(), True)
|
||||||
|
|
||||||
request.state.user = user
|
request.state.user = user
|
||||||
|
request.state.principal = who
|
||||||
|
|
||||||
|
# 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest —
|
||||||
|
# a konto ograniczone ma widzieć program kompletny, tylko mniejszy.
|
||||||
|
need = features.required(request.method, request.url.path)
|
||||||
|
if not features.can(who.permissions, need):
|
||||||
|
_audit(request, user, 404, (time.monotonic() - started) * 1000)
|
||||||
|
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
|
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
|
|||||||
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
|
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
|
||||||
}
|
}
|
||||||
body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */
|
body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */
|
||||||
|
|
||||||
|
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
|
||||||
|
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
|
||||||
|
(1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
|
||||||
|
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
|
||||||
|
.wheel-tip {
|
||||||
|
position: fixed; z-index: 1150; pointer-events: none;
|
||||||
|
max-width: 22rem; padding: .4rem .6rem;
|
||||||
|
background: var(--panel); color: var(--fg, inherit);
|
||||||
|
border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
font-size: .85rem; line-height: 1.35;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
|
||||||
|
}
|
||||||
|
.wheel-tip[hidden] { display: none; }
|
||||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||||
.row { display: flex; gap: .5rem; }
|
.row { display: flex; gap: .5rem; }
|
||||||
.row input[type=text] { flex: 1; }
|
.row input[type=text] { flex: 1; }
|
||||||
@@ -91,6 +105,16 @@ input, select, button {
|
|||||||
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
|
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
|
||||||
button:hover { filter: brightness(1.1); }
|
button:hover { filter: brightness(1.1); }
|
||||||
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
|
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
|
||||||
|
|
||||||
|
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
|
||||||
|
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
|
||||||
|
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
|
||||||
|
kolumn jest po prostu mniej. */
|
||||||
|
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
|
||||||
|
gap: .35rem 1rem; align-items: start; }
|
||||||
|
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
|
||||||
|
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
|
||||||
|
.opts .hint { font-style: normal; opacity: .75; }
|
||||||
.opts input[type=number] { width: 5rem; }
|
.opts input[type=number] { width: 5rem; }
|
||||||
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
||||||
|
|
||||||
@@ -108,6 +132,21 @@ button:hover { filter: brightness(1.1); }
|
|||||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||||
.empty { color: var(--muted); }
|
.empty { color: var(--muted); }
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
|
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
|
||||||
|
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
|
||||||
|
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
|
||||||
|
się sama tabela, nigdy strona. */
|
||||||
|
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
|
||||||
|
.table-scroll > table { margin-top: 0; border-radius: 0; }
|
||||||
|
.table-scroll th, .table-scroll td { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
|
||||||
|
nie wiadomo, który to wiersz. */
|
||||||
|
.table-scroll th:first-child, .table-scroll td:first-child {
|
||||||
|
position: sticky; left: 0; background: var(--panel); z-index: 1;
|
||||||
|
box-shadow: 1px 0 0 var(--line);
|
||||||
|
}
|
||||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
||||||
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
tr:last-child td { border-bottom: none; }
|
tr:last-child td { border-bottom: none; }
|
||||||
@@ -189,3 +228,22 @@ textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|||||||
bez przepisywania ich na HTML. */
|
bez przepisywania ich na HTML. */
|
||||||
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
|
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
|
||||||
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
|
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */
|
||||||
|
.account-card {
|
||||||
|
margin-top: 1rem; padding: 1rem 1.25rem;
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||||
|
}
|
||||||
|
.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; }
|
||||||
|
.account-note input { width: 100%; }
|
||||||
|
.account-card .actions { margin-top: .9rem; }
|
||||||
|
button.danger { background: #8b2f2f; }
|
||||||
|
|
||||||
|
/* Potwierdzenie operacji. Odróżnia się od .house-warning, bo tamto ostrzega,
|
||||||
|
a to tylko potwierdza — pomylenie ich uczy ignorować oba. */
|
||||||
|
.done-note {
|
||||||
|
margin: 1rem 0; padding: .6rem .9rem; font-size: .9rem;
|
||||||
|
border: 1px solid var(--line); border-left: 4px solid var(--accent);
|
||||||
|
border-radius: 4px; background: rgba(255, 255, 255, .03);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Dymki na kosmogramie — własne, zamiast natywnych z <title> (PRE-12/PRE-25).
|
||||||
|
//
|
||||||
|
// DLACZEGO NIE ZOSTAWIAMY NATYWNYCH. Rysunek niesie <title> przy każdym obiekcie
|
||||||
|
// i każdej linii aspektu i to wystarczało, dopóki koło było statyczne. Po dodaniu
|
||||||
|
// powiększania na pełne okno dymki przestały się pokazywać — a niezależnie od tego,
|
||||||
|
// która reguła przeglądarki je tłumi, natywny dymek i tak jest tu kiepskim
|
||||||
|
// narzędziem: pojawia się po sekundzie zwłoki, nie da się go stylować, nie działa
|
||||||
|
// na dotyku i nie ma go czym wywołać z klawiatury.
|
||||||
|
//
|
||||||
|
// Sprawdzone w przeglądarce (1280×900, komplet 13 systemów): elementy z <title>
|
||||||
|
// SĄ osiągalne kursorem — 19 z 20 trafień w hit-teście — więc nic ich nie zasłania
|
||||||
|
// i problem nie leży w geometrii ani w pointer-events.
|
||||||
|
//
|
||||||
|
// <title> ZOSTAJE w rysunku: to nazwa dostępna dla czytników ekranu, a PDF składa
|
||||||
|
// się po stronie serwera, więc ta ścieżka jest nietknięta. Na czas najechania
|
||||||
|
// odpinamy go i wieszamy z powrotem po zejściu kursora — dzięki temu nigdy nie
|
||||||
|
// widać dwóch dymków naraz, gdyby przeglądarka jednak swój pokazała.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var OFFSET = 14; // odsunięcie od kursora, żeby dymek nie siedział pod nim
|
||||||
|
var stash = null; // { owner, node } — odpięty <title> do przywieszenia
|
||||||
|
|
||||||
|
function ready(fn) {
|
||||||
|
if (document.readyState !== 'loading') fn();
|
||||||
|
else document.addEventListener('DOMContentLoaded', fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
ready(function () {
|
||||||
|
var wheels = document.querySelectorAll('svg.wheel');
|
||||||
|
if (!wheels.length) return;
|
||||||
|
|
||||||
|
var tip = document.createElement('div');
|
||||||
|
tip.className = 'wheel-tip';
|
||||||
|
tip.setAttribute('role', 'tooltip');
|
||||||
|
tip.hidden = true;
|
||||||
|
document.body.appendChild(tip);
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
if (!stash) return;
|
||||||
|
stash.owner.appendChild(stash.node);
|
||||||
|
stash = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
tip.hidden = true;
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element z własnym <title>, licząc od celu zdarzenia w górę. Aspekty to
|
||||||
|
// cienkie linie, a glify siedzą w <text> — oba trzymają <title> jako dziecko.
|
||||||
|
function owner(el, root) {
|
||||||
|
while (el && el !== root) {
|
||||||
|
if (el.querySelector && el.querySelector(':scope > title')) return el;
|
||||||
|
el = el.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function place(x, y) {
|
||||||
|
// Ustawiamy najpierw, potem mierzymy — inaczej nie znamy rozmiaru dymka.
|
||||||
|
tip.style.left = '0px';
|
||||||
|
tip.style.top = '0px';
|
||||||
|
var w = tip.offsetWidth, h = tip.offsetHeight;
|
||||||
|
var left = x + OFFSET, top = y + OFFSET;
|
||||||
|
if (left + w > window.innerWidth - 4) left = x - OFFSET - w; // odbij w lewo
|
||||||
|
if (top + h > window.innerHeight - 4) top = y - OFFSET - h; // odbij w górę
|
||||||
|
tip.style.left = Math.max(4, left) + 'px';
|
||||||
|
tip.style.top = Math.max(4, top) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(wheels, function (svg) {
|
||||||
|
svg.addEventListener('mousemove', function (e) {
|
||||||
|
var el = owner(e.target, svg);
|
||||||
|
if (!el) { hide(); return; }
|
||||||
|
|
||||||
|
if (!stash || stash.owner !== el) {
|
||||||
|
restore();
|
||||||
|
var node = el.querySelector(':scope > title');
|
||||||
|
tip.textContent = node.textContent;
|
||||||
|
stash = { owner: el, node: node };
|
||||||
|
el.removeChild(node);
|
||||||
|
tip.hidden = false;
|
||||||
|
}
|
||||||
|
place(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener('mouseleave', hide);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dymek nie może przeżyć przewinięcia ani powiększenia — zostałby wiszący
|
||||||
|
// w miejscu, w którym nic już nie ma.
|
||||||
|
window.addEventListener('scroll', hide, true);
|
||||||
|
window.addEventListener('resize', hide);
|
||||||
|
document.addEventListener('click', hide, true);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -1,19 +1,26 @@
|
|||||||
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i „Skompiluj" (/compile). Jeden plik,
|
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i „Skompiluj" (/compile). Jeden plik,
|
||||||
bo dodawaliśmy je już 3× w dwóch formularzach i groziło rozjechaniem (regresja
|
bo dodawaliśmy je już 3× w dwóch formularzach i groziło rozjechaniem (regresja
|
||||||
podsumowania). Wszystkie synchronizowane między zakładkami przez formsync.js. #}
|
podsumowania). Wszystkie synchronizowane między zakładkami przez formsync.js. #}
|
||||||
|
{% if can(request, 'advanced_calc') %}
|
||||||
<div class="opts">
|
<div class="opts">
|
||||||
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||||
licz stacje planet (wolniejsze)</label>
|
licz stacje planet (wolniejsze)</label>
|
||||||
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
|
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
|
||||||
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
|
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="opts" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
|
{% endif %}
|
||||||
|
{% if can(request, 'houses_compare') %}
|
||||||
|
<div class="opts systems" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
|
||||||
{% set chosen = form.house_systems or [] %}
|
{% set chosen = form.house_systems or [] %}
|
||||||
<span class="muted small">Porównaj systemy domów:</span>
|
<span class="muted small opts-label">Porównaj systemy domów
|
||||||
|
<em class="hint">— gwiazdka: nie działa za kołem podbiegunowym (ok. 66,5°); tam policzymy Porphyry i wyraźnie o tym napiszemy</em>
|
||||||
|
</span>
|
||||||
{% for value, name in HOUSE_SYSTEMS %}
|
{% for value, name in HOUSE_SYSTEMS %}
|
||||||
<label><input type="checkbox" name="house_systems" value="{{ value }}" {{ 'checked' if value in chosen else '' }}> {{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</label>
|
<label><input type="checkbox" name="house_systems" value="{{ value }}" {{ 'checked' if value in chosen else '' }}> {{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if can(request, 'houses_compare') %}
|
||||||
<div class="opts" title="Zmienia wyłącznie OBRÓT rysunku — żadna liczba nie jest przeliczana. „Ascendent po lewej” to tradycja zachodnia: dom I zawsze zaczyna się w tym samym miejscu. „0° Barana po lewej” unieruchamia koło względem zodiaku, dzięki czemu dwa horoskopy da się porównywać na oko.">
|
<div class="opts" title="Zmienia wyłącznie OBRÓT rysunku — żadna liczba nie jest przeliczana. „Ascendent po lewej” to tradycja zachodnia: dom I zawsze zaczyna się w tym samym miejscu. „0° Barana po lewej” unieruchamia koło względem zodiaku, dzięki czemu dwa horoskopy da się porównywać na oko.">
|
||||||
{% set wo = form.wheel_orientation or 'asc' %}
|
{% set wo = form.wheel_orientation or 'asc' %}
|
||||||
<span class="muted small">Kosmogram — po lewej stronie:</span>
|
<span class="muted small">Kosmogram — po lewej stronie:</span>
|
||||||
@@ -21,8 +28,11 @@
|
|||||||
<label><input type="radio" name="wheel_orientation" value="{{ value }}" {{ 'checked' if wo == value else '' }}> {{ name }}</label>
|
<label><input type="radio" name="wheel_orientation" value="{{ value }}" {{ 'checked' if wo == value else '' }}> {{ name }}</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if can(request, 'advanced_calc') %}
|
||||||
<div class="opts" title="Orb = dopuszczalne odchylenie od dokładnego kąta aspektu. „Bonus świateł" powiększa orb dla aspektów ze Słońcem/Księżycem. Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150° (PRE-06).">
|
<div class="opts" title="Orb = dopuszczalne odchylenie od dokładnego kąta aspektu. „Bonus świateł" powiększa orb dla aspektów ze Słońcem/Księżycem. Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150° (PRE-06).">
|
||||||
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
|
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
|
||||||
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
|
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
|
||||||
<label><input type="checkbox" name="aspect_minor" value="true" {{ 'checked' if form.aspect_minor else '' }}> aspekty poboczne (30/45/150°)</label>
|
<label><input type="checkbox" name="aspect_minor" value="true" {{ 'checked' if form.aspect_minor else '' }}> aspekty poboczne (30/45/150°)</label>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #}
|
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #}
|
||||||
{% if result.house_systems and result.house_systems | length > 1 %}
|
{% if result.house_systems and result.house_systems | length > 1 %}
|
||||||
<div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div>
|
<div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div>
|
||||||
|
{# Przy komplecie systemów tabela jest szersza niż okno — przewija się SAMA,
|
||||||
|
zamiast rozpychać stronę (numer domu zostaje przyklejony do lewej). #}
|
||||||
|
<div class="table-scroll">
|
||||||
<table class="angles">
|
<table class="angles">
|
||||||
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ house_label(hs.system) }}</th>{% endfor %}</tr></thead>
|
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ house_label(hs.system) }}</th>{% endfor %}</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -41,6 +44,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set nav_active = "accounts" %}
|
||||||
|
{% block title %}Konta{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p class="muted">
|
||||||
|
Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong>
|
||||||
|
pochodzi z konfiguracji środowiska, ma wszystkie uprawnienia i jest jedynym,
|
||||||
|
które może zarządzać pozostałymi — dlatego nie ma go na tej liście i nie da się
|
||||||
|
go stąd skasować.
|
||||||
|
</p>
|
||||||
|
<p class="muted small">
|
||||||
|
Osoba na koncie ograniczonym widzi wyłącznie przyznane jej zakładki i opcje.
|
||||||
|
Nic w programie nie pokazuje jej, że istnieje coś więcej — niedostępne adresy
|
||||||
|
odpowiadają tak samo jak nieistniejące.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %}
|
||||||
|
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
||||||
|
|
||||||
|
<div class="meta">Konta ({{ users | length }})</div>
|
||||||
|
{% if not users %}
|
||||||
|
<p class="muted">Nie ma jeszcze żadnego konta poza administracyjnym.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for login, row in users | dictsort %}
|
||||||
|
<form method="post" action="/accounts/update" class="account-card">
|
||||||
|
<input type="hidden" name="login" value="{{ login }}">
|
||||||
|
<div class="account-head">
|
||||||
|
<strong>{{ login }}</strong>
|
||||||
|
<span class="muted small">{{ row.created[:10] }}</span>
|
||||||
|
</div>
|
||||||
|
<label class="account-note">Opis
|
||||||
|
<input type="text" name="note" value="{{ row.note }}" placeholder="np. do czego to konto służy">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
|
||||||
|
<div class="opts systems" title="{{ 'Zakładki widoczne w nawigacji.' if group == 'Ekrany' else 'Poziomy złożoności wewnątrz ekranów.' }}">
|
||||||
|
<span class="muted small opts-label">{{ group }}</span>
|
||||||
|
{% for f in items %}
|
||||||
|
<label title="{{ f.hint }}">
|
||||||
|
<input type="checkbox" name="granted" value="{{ f.key }}"
|
||||||
|
{{ 'checked' if f.key in row.features else '' }}> {{ f.label }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="opts">
|
||||||
|
<label>Nowe hasło (puste = bez zmiany)
|
||||||
|
<input type="password" name="password" autocomplete="new-password" placeholder="••••••">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Zapisz</button>
|
||||||
|
<button type="submit" formaction="/accounts/delete" class="danger"
|
||||||
|
onclick="return confirm('Skasować konto {{ login }}? Tej operacji nie da się cofnąć.')">
|
||||||
|
Skasuj konto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="meta">Nowe konto</div>
|
||||||
|
<form method="post" action="/accounts/create" class="account-card">
|
||||||
|
<div class="grid">
|
||||||
|
<label>Login <input type="text" name="login" required autocomplete="off"></label>
|
||||||
|
<label>Hasło <input type="password" name="password" required autocomplete="new-password"></label>
|
||||||
|
<label>Opis <input type="text" name="note" placeholder="np. do czego to konto służy"></label>
|
||||||
|
</div>
|
||||||
|
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
|
||||||
|
<div class="opts systems">
|
||||||
|
<span class="muted small opts-label">{{ group }}</span>
|
||||||
|
{% for f in items %}
|
||||||
|
<label title="{{ f.hint }}"><input type="checkbox" name="granted" value="{{ f.key }}"> {{ f.label }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="actions"><button type="submit">Załóż konto</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<details class="loc">
|
||||||
|
<summary>Gdzie to jest zapisane</summary>
|
||||||
|
<p class="muted small">
|
||||||
|
Konta trzymamy w pliku <code>{{ store_path }}</code> — hasła wyłącznie jako
|
||||||
|
hash scrypt, nigdy jawnie. Zapis jest atomowy, więc przerwanie w trakcie nie
|
||||||
|
obetnie pliku i nie skasuje wszystkich kont naraz.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
{% endblock %}
|
||||||
@@ -13,19 +13,22 @@
|
|||||||
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
|
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
|
||||||
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
|
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
|
||||||
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
||||||
|
<script defer src="{{ static('wheeltip.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<h1>astrololo</h1>
|
<h1>astrololo</h1>
|
||||||
|
{# Zakładki WYŁĄCZNIE z uprawnień konta. Nie wyszarzamy niedostępnych —
|
||||||
|
ich po prostu nie ma, bo sama obecność wyszarzonej pozycji mówi, że
|
||||||
|
program umie więcej, niż pokazuje (PRE-26). #}
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
|
{% for tab in nav_screens(request) %}
|
||||||
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
|
<a href="{{ tab.href }}" class="{{ 'active' if nav_active is defined and nav_active == tab.key else '' }}">{{ tab.label }}</a>
|
||||||
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
|
{% endfor %}
|
||||||
<a href="/synastry" class="{% block nav_synastry %}{% endblock %}">Synastria</a>
|
{% if can(request, 'admin') %}
|
||||||
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
<a href="/accounts" class="{{ 'active' if nav_active is defined and nav_active == 'accounts' else '' }}">Konta</a>
|
||||||
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
|
{% endif %}
|
||||||
<a href="/settings" class="{% block nav_settings %}{% endblock %}">Ustawienia</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Horoskop{% endblock %}
|
{% block title %}Horoskop{% endblock %}
|
||||||
{% block nav_chart %}active{% endblock %}
|
{% set nav_active = "chart" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
<label>Długość (lon, + E)
|
<label>Długość (lon, + E)
|
||||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||||
</label>
|
</label>
|
||||||
|
{% if can(request, 'houses_compare') %}
|
||||||
<label>System domów
|
<label>System domów
|
||||||
<select name="house_system">
|
<select name="house_system">
|
||||||
{% set hs = form.house_system or 'whole_sign' %}
|
{% set hs = form.house_system or 'whole_sign' %}
|
||||||
@@ -38,8 +39,9 @@
|
|||||||
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<span class="muted small" title="Placidus i Koch dzielą łuk dobowy, który powyżej koła podbiegunowego (ok. 66,5°) nie istnieje — dla takich miejsc policzymy Porphyry i wyraźnie o tym napiszemy.">* nie działa za kołem podbiegunowym</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% if can(request, 'advanced_calc') %}
|
||||||
<label>Zodiak
|
<label>Zodiak
|
||||||
<select name="zodiac">
|
<select name="zodiac">
|
||||||
{% set zd = form.zodiac or 'tropical' %}
|
{% set zd = form.zodiac or 'tropical' %}
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
|
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% include "_location_picker.html" %}
|
{% include "_location_picker.html" %}
|
||||||
{% include "_form_options.html" %}
|
{% include "_form_options.html" %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Skompiluj{% endblock %}
|
{% block title %}Skompiluj{% endblock %}
|
||||||
{% block nav_compile %}active{% endblock %}
|
{% set nav_active = "compile" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
|
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
|
||||||
@@ -36,6 +36,7 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
|||||||
<label>Długość (lon, + E)
|
<label>Długość (lon, + E)
|
||||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||||
</label>
|
</label>
|
||||||
|
{% if can(request, 'houses_compare') %}
|
||||||
<label>System domów
|
<label>System domów
|
||||||
<select name="house_system">
|
<select name="house_system">
|
||||||
{% set hs = form.house_system or 'whole_sign' %}
|
{% set hs = form.house_system or 'whole_sign' %}
|
||||||
@@ -43,8 +44,9 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
|||||||
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<span class="muted small" title="Placidus i Koch dzielą łuk dobowy, który powyżej koła podbiegunowego (ok. 66,5°) nie istnieje — dla takich miejsc policzymy Porphyry i wyraźnie o tym napiszemy.">* nie działa za kołem podbiegunowym</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% if can(request, 'advanced_calc') %}
|
||||||
<label>Zodiak
|
<label>Zodiak
|
||||||
<select name="zodiac">
|
<select name="zodiac">
|
||||||
{% set zd = form.zodiac or 'tropical' %}
|
{% set zd = form.zodiac or 'tropical' %}
|
||||||
@@ -55,15 +57,16 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
|||||||
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
|
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{# Te same opcje co na „Horoskop" (wspólny plik) — synchronizowane między
|
{# Te same opcje co na „Horoskop" (wspólny plik) — synchronizowane między
|
||||||
zakładkami (formsync), żeby podsumowanie liczyło się z tym, co wybrano. #}
|
zakładkami (formsync), żeby podsumowanie liczyło się z tym, co wybrano. #}
|
||||||
{% include "_form_options.html" %}
|
{% include "_form_options.html" %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Złóż podsumowanie</button>
|
<button type="submit">Złóż podsumowanie</button>
|
||||||
<button type="button" id="pdfBtn" class="ghost"
|
{% if can(request, 'export') %}<button type="button" id="pdfBtn" class="ghost"
|
||||||
title="Generuje plik pośredni LaTeXa i kompiluje go do PDF (usługa render)">Pobierz PDF</button>
|
title="Generuje plik pośredni LaTeXa i kompiluje go do PDF (usługa render)">Pobierz PDF</button>
|
||||||
<span id="pdfNote" class="muted small"></span>
|
<span id="pdfNote" class="muted small"></span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Interpretacje{% endblock %}
|
{% block title %}Interpretacje{% endblock %}
|
||||||
{% block nav_interp %}active{% endblock %}
|
{% set nav_active = "interpret" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
||||||
<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>
|
{% if can(request, 'export') %}<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>{% endif %}
|
||||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||||
<span id="geoNote" class="muted small"></span>
|
<span id="geoNote" class="muted small"></span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Ustawienia{% endblock %}
|
{% block title %}Ustawienia{% endblock %}
|
||||||
{% block nav_settings %}active{% endblock %}
|
{% set nav_active = "settings" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
|
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Sygnifikatory{% endblock %}
|
{% block title %}Sygnifikatory{% endblock %}
|
||||||
{% block nav_sig %}active{% endblock %}
|
{% set nav_active = "significators" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
|
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Synastria{% endblock %}
|
{% block title %}Synastria{% endblock %}
|
||||||
{% block nav_synastry %}active{% endblock %}
|
{% set nav_active = "synastry" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
|
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Kalendarz{% endblock %}
|
{% block title %}Kalendarz{% endblock %}
|
||||||
{% block nav_timeline %}active{% endblock %}
|
{% set nav_active = "timeline" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
||||||
|
|||||||
@@ -31,7 +31,16 @@ def _client():
|
|||||||
# ───────────────────────────────── nawigacja i trasa ─────────────────────
|
# ───────────────────────────────── nawigacja i trasa ─────────────────────
|
||||||
|
|
||||||
def test_tab_is_in_the_menu():
|
def test_tab_is_in_the_menu():
|
||||||
assert 'href="/compile"' in BASE and "Skompiluj" in BASE
|
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
|
||||||
|
|
||||||
|
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
|
||||||
|
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
|
||||||
|
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
|
||||||
|
from app import features
|
||||||
|
|
||||||
|
tab = features.BY_KEY["compile"]
|
||||||
|
assert tab in features.SCREENS
|
||||||
|
assert tab.href and tab.label
|
||||||
|
|
||||||
|
|
||||||
def test_get_renders_empty_form():
|
def test_get_renders_empty_form():
|
||||||
@@ -203,11 +212,25 @@ def test_compile_form_carries_the_options():
|
|||||||
assert f in opts, f"brak opcji we wspólnym pliku: {f}"
|
assert f in opts, f"brak opcji we wspólnym pliku: {f}"
|
||||||
|
|
||||||
|
|
||||||
def test_compile_handler_recomputes_with_all_options():
|
def test_compile_handler_recomputes_with_all_options(monkeypatch):
|
||||||
assert "house_systems: list[str] = Form([])" in MAIN
|
"""„Skompiluj" przelicza horoskop z KOMPLETEM opcji z formularza.
|
||||||
assert "stations: bool = Form(False)" in MAIN
|
|
||||||
assert "house_systems=house_systems" in MAIN # przekazane do logiki
|
To jest ta regresja z 2026-07-28: podsumowanie liczyło się z okrojonym
|
||||||
assert "stations=stations, zodiac=zodiac, tables=tables" in MAIN
|
zestawem i miało braki względem tego, co widać na „Horoskopie". Sprawdzamy
|
||||||
|
przez wywołanie trasy, nie przez szukanie napisów w main.py."""
|
||||||
|
seen = {}
|
||||||
|
from app.main import app, logic
|
||||||
|
|
||||||
|
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _pdf_sample_chart())[1])
|
||||||
|
r = _client().post("/compile", data={
|
||||||
|
"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||||
|
"lat": "50.06", "lon": "19.94", "house_system": "koch",
|
||||||
|
"house_systems": ["koch", "equal"], "stations": "true", "tables": "true",
|
||||||
|
"zodiac": "sidereal_lahiri", "aspect_minor": "true"})
|
||||||
|
assert r.status_code == 200, r.text[:300]
|
||||||
|
assert seen["house_systems"] == ["koch", "equal"]
|
||||||
|
assert seen["stations"] is True and seen["tables"] is True
|
||||||
|
assert seen["zodiac"] == "sidereal_lahiri" and seen["aspect_minor"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_compile_pdf_recomputes_with_all_options():
|
def test_compile_pdf_recomputes_with_all_options():
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ def _render(name: str, **ctx) -> str:
|
|||||||
|
|
||||||
env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True)
|
env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True)
|
||||||
env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED,
|
env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED,
|
||||||
house_label=house_label, static=lambda p: p)
|
house_label=house_label, static=lambda p: p,
|
||||||
|
# Domyślnie renderujemy jak dla konta z PEŁNYMI uprawnieniami —
|
||||||
|
# inaczej testy opcji sprawdzałyby pusty formularz. Ograniczenia
|
||||||
|
# mają własne testy w test_kontrola_dostepu.py.
|
||||||
|
can=lambda request, key: ctx.pop("_denied", None) != key,
|
||||||
|
nav_screens=lambda request: [])
|
||||||
return env.get_template(name).render(**ctx)
|
return env.get_template(name).render(**ctx)
|
||||||
|
|
||||||
|
|
||||||
@@ -57,9 +62,26 @@ def test_form_marks_systems_with_a_domain_limit():
|
|||||||
assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}"
|
assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}"
|
||||||
|
|
||||||
|
|
||||||
def test_handler_accepts_and_passes_house_systems():
|
def test_handler_accepts_and_passes_house_systems(monkeypatch):
|
||||||
assert "house_systems: list[str] = Form([])" in MAIN
|
"""Zaznaczone systemy DOCHODZĄ do warstwy logicznej.
|
||||||
assert "house_systems=house_systems" in MAIN # przekazane do logiki
|
|
||||||
|
Poprzednia wersja szukała napisu `house_systems=house_systems` w main.py
|
||||||
|
i pękała, gdy argument zaczął przechodzić przez słownik opcji — mimo że
|
||||||
|
zachowanie zostało poprawne. Sprawdzamy więc, co naprawdę wyszło na dół."""
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def spy(**kw):
|
||||||
|
seen.update(kw)
|
||||||
|
return _minimal_chart()
|
||||||
|
|
||||||
|
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
|
||||||
|
from app.main import logic
|
||||||
|
|
||||||
|
monkeypatch.setattr(logic, "positions", spy)
|
||||||
|
r = c.post("/", data={**FORM, "house_systems": ["koch", "campanus"]})
|
||||||
|
assert r.status_code == 200, r.text[:300]
|
||||||
|
assert seen["house_systems"] == ["koch", "campanus"]
|
||||||
|
assert seen["house_system"] == "placidus"
|
||||||
|
|
||||||
|
|
||||||
def test_client_forwards_house_systems_to_logic():
|
def test_client_forwards_house_systems_to_logic():
|
||||||
@@ -154,3 +176,56 @@ def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
|
|||||||
r = c.post("/", data={**FORM, "wheel_orientation": orientation})
|
r = c.post("/", data={**FORM, "wheel_orientation": orientation})
|
||||||
assert r.status_code == 200, r.text[:400]
|
assert r.status_code == 200, r.text[:400]
|
||||||
assert f'value="{orientation}" checked' in r.text
|
assert f'value="{orientation}" checked' in r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ── układ strony przy komplecie systemów ────────────────────────────────
|
||||||
|
# Regresja z pomiaru na żywej stronie: przy 13 zaznaczonych systemach tabela
|
||||||
|
# porównania miała 14 kolumn i 1863 px szerokości, a że stała w rodzicu bez
|
||||||
|
# overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli
|
||||||
|
# poziomy pasek przewijania na body i rozjechany układ.
|
||||||
|
|
||||||
|
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_comparison_table_scrolls_instead_of_widening_the_page():
|
||||||
|
"""Tabela ma własny kontener przewijany — przewija się ona, nie strona."""
|
||||||
|
html = _render("_result_tables.html", result={
|
||||||
|
"house_systems": [{"system": v, "cusps": [
|
||||||
|
{"sign_glyph": "♈", "in_sign": "0°00'00''"} for _ in range(12)]}
|
||||||
|
for v, _ in HOUSE_SYSTEMS],
|
||||||
|
"house_warnings": []})
|
||||||
|
assert '<div class="table-scroll">' in html
|
||||||
|
before = html.index('class="table-scroll"')
|
||||||
|
assert html.index("<table", before) < html.index("</div>", before), \
|
||||||
|
"tabela musi być WEWNĄTRZ kontenera przewijanego"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scroll_container_actually_clips_and_pins_the_house_number():
|
||||||
|
"""Sam znacznik nie wystarczy — reguła musi istnieć w arkuszu.
|
||||||
|
|
||||||
|
Geometrii nie da się sprawdzić bez przeglądarki, więc sprawdzamy kontrakt:
|
||||||
|
kontener przewija się w poziomie, a pierwsza kolumna zostaje przyklejona
|
||||||
|
(bez tego po przewinięciu w prawo nie wiadomo, który to dom)."""
|
||||||
|
assert ".table-scroll { overflow-x: auto;" in CSS
|
||||||
|
assert ".table-scroll th:first-child, .table-scroll td:first-child {" in CSS
|
||||||
|
assert "position: sticky" in CSS
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_checkboxes_use_a_grid_not_a_ragged_flex_wrap():
|
||||||
|
"""13 pozycji na flexie zawijało się w poszarpane wiersze o różnej długości."""
|
||||||
|
html = _render("_form_options.html", form={})
|
||||||
|
assert 'class="opts systems"' in html
|
||||||
|
assert ".opts.systems { display: grid;" in CSS
|
||||||
|
|
||||||
|
|
||||||
|
def test_polar_circle_note_is_explained_once_next_to_the_asterisks():
|
||||||
|
"""Przypis siedział WEWNĄTRZ <label> selektora i łamał się na dwie linie,
|
||||||
|
przez co wiersz siatki rósł z 66 do 108 px i rozjeżdżał się z sąsiednim
|
||||||
|
polem. Wyjaśnienie stoi teraz raz, przy checkboxach z gwiazdkami."""
|
||||||
|
opts = _render("_form_options.html", form={})
|
||||||
|
assert "kołem podbiegunowym" in opts
|
||||||
|
for name in ("chart.html", "compile.html"):
|
||||||
|
tpl = (APP / "templates" / name).read_text(encoding="utf-8")
|
||||||
|
select = tpl[tpl.index("<select name=\"house_system\""):]
|
||||||
|
assert "podbiegunow" not in select[:select.index("</label>")], \
|
||||||
|
f"{name}: przypis wrócił do etykiety selektora"
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
"""Kontrola dostępu: konta, uprawnienia i niewidzialność funkcji (PRE-27).
|
||||||
|
|
||||||
|
DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK
|
||||||
|
|
||||||
|
1. Konto widzi dokładnie to, co mu przyznano — ani mniej, ani więcej. Sprawdzane
|
||||||
|
przez PRAWDZIWE żądania, nie przez czytanie szablonów: ukrycie pola w formularzu
|
||||||
|
nie chroni przed kimś, kto zna nazwy pól.
|
||||||
|
|
||||||
|
2. Konto ograniczone nie ma SKĄD wiedzieć, że program umie więcej. To własność
|
||||||
|
negatywna — łatwo ją zepsuć przez dobre intencje („dodajmy czytelny komunikat
|
||||||
|
o braku uprawnień"), więc testy są tu wprost o tym: 404 zamiast 403, brak
|
||||||
|
pozycji w menu, brak rysunków w źródle strony.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import accounts as store
|
||||||
|
from app import features, security
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(tmp_path, monkeypatch):
|
||||||
|
"""Świeży plik kont + konto administracyjne ze środowiska."""
|
||||||
|
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
|
||||||
|
monkeypatch.setenv("APP_USER", "szef")
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||||
|
monkeypatch.delenv("APP_USERS", raising=False)
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") # limit nie jest tu przedmiotem badania
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _client(monkeypatch, chart=None):
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app, logic
|
||||||
|
|
||||||
|
monkeypatch.setattr(logic, "positions", lambda **kw: chart or _chart())
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(user, password):
|
||||||
|
import base64
|
||||||
|
|
||||||
|
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||||
|
return {"Authorization": f"Basic {raw}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _chart():
|
||||||
|
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0", "decimal": float(i * 30),
|
||||||
|
"sign_glyph": "♈"} for i in range(12)]
|
||||||
|
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0", "decimal": 0.0,
|
||||||
|
"sign_glyph": "♈"} for k in ("Asc", "MC", "Dsc", "IC")}
|
||||||
|
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
|
||||||
|
"sign_glyphs": [{"sign": "Aries", "glyph": "♈"}], "house_system": "whole_sign",
|
||||||
|
"aspects": [], "house_systems": []}
|
||||||
|
|
||||||
|
|
||||||
|
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||||
|
"lat": "50.06", "lon": "19.94"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── katalog i mapa tras ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_every_route_declares_the_permission_it_needs():
|
||||||
|
"""Trasa bez wpisu w ROUTES to dziura, której nie widać.
|
||||||
|
|
||||||
|
Idziemy po TRASACH APLIKACJI, nie po wpisach mapy — inaczej test
|
||||||
|
potwierdzałby tylko sam siebie i nie zauważyłby nowej, nieopisanej trasy."""
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for route in app.routes:
|
||||||
|
path = getattr(route, "path", None)
|
||||||
|
methods = getattr(route, "methods", None) or set()
|
||||||
|
if not path or path.startswith("/static"):
|
||||||
|
continue
|
||||||
|
for method in methods & {"GET", "POST"}:
|
||||||
|
if (method, path) not in features.ROUTES:
|
||||||
|
missing.append(f"{method} {path}")
|
||||||
|
assert not missing, f"trasy bez zadeklarowanego uprawnienia: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_route_defaults_to_the_narrowest_permission():
|
||||||
|
"""Przeoczenie ma ZAMYKAĆ, nie otwierać."""
|
||||||
|
assert features.required("GET", "/cokolwiek-nowego") == features.ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_permission_cannot_be_granted_from_a_form():
|
||||||
|
"""Nawet spreparowane żądanie nie nada uprawnień administracyjnych —
|
||||||
|
konto administracyjne pochodzi wyłącznie ze środowiska."""
|
||||||
|
assert features.ADMIN not in features.normalise([features.ADMIN, "chart"])
|
||||||
|
assert features.normalise(["chart", "wymyślone"]) == frozenset({"chart"})
|
||||||
|
|
||||||
|
|
||||||
|
# ── magazyn kont ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_password_is_stored_only_as_a_hash(env):
|
||||||
|
store.create("ala", "hasło-ali", ["chart"])
|
||||||
|
raw = pathlib.Path(store.store_path()).read_text(encoding="utf-8")
|
||||||
|
assert "hasło-ali" not in raw
|
||||||
|
assert json.loads(raw)["users"]["ala"]["secret"].startswith("scrypt$")
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_accounts_never_exposes_secrets(env):
|
||||||
|
store.create("ala", "hasło-ali", ["chart"])
|
||||||
|
assert "secret" not in store.all_users()["ala"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_update_delete(env):
|
||||||
|
store.create("ala", "x", ["chart"], note="do testów")
|
||||||
|
assert store.permissions_of("ala") == frozenset({"chart"})
|
||||||
|
store.update("ala", granted=["chart", "ai"])
|
||||||
|
assert store.permissions_of("ala") == frozenset({"chart", "ai"})
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
store.create("ala", "y", []) # login zajęty
|
||||||
|
store.delete("ala")
|
||||||
|
assert not store.exists("ala")
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_password_on_update_keeps_the_old_one(env):
|
||||||
|
store.create("ala", "stare", ["chart"])
|
||||||
|
before = store.secret_of("ala")
|
||||||
|
store.update("ala", granted=["chart"], password="")
|
||||||
|
assert store.secret_of("ala") == before
|
||||||
|
|
||||||
|
|
||||||
|
# ── logowanie i rozpoznanie konta ────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_environment_account_is_the_administrator(env):
|
||||||
|
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
|
||||||
|
assert who and who.is_admin
|
||||||
|
assert features.ADMIN in who.permissions
|
||||||
|
assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko"
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_account_gets_exactly_its_own_permissions(env):
|
||||||
|
store.create("ala", "hasło-ali", ["chart", "ai"])
|
||||||
|
who = security.principal(_auth("ala", "hasło-ali")["Authorization"])
|
||||||
|
assert who and not who.is_admin
|
||||||
|
assert who.permissions == frozenset({"chart", "ai"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_account_cannot_shadow_the_administrator(env):
|
||||||
|
"""Konto założone w pliku o loginie administratora NIE MOŻE go przesłonić —
|
||||||
|
inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać."""
|
||||||
|
store.create("szef", "podszywam-się", [])
|
||||||
|
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
|
||||||
|
assert who and who.is_admin and features.ADMIN in who.permissions
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_password_is_refused_the_same_way_as_unknown_login(env):
|
||||||
|
store.create("ala", "hasło-ali", ["chart"])
|
||||||
|
assert security.principal(_auth("ala", "złe")["Authorization"]) is None
|
||||||
|
assert security.principal(_auth("nie-ma-takiego", "cokolwiek")["Authorization"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── niewidzialność funkcji ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_forbidden_screen_answers_404_not_403(env, monkeypatch):
|
||||||
|
"""403 samo w sobie mówi „tu coś jest". Ma być nie do odróżnienia od
|
||||||
|
adresu, którego nie ma."""
|
||||||
|
store.create("ala", "x", ["chart"])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
for path in ("/significators", "/interpret", "/timeline", "/synastry",
|
||||||
|
"/compile", "/settings", "/accounts"):
|
||||||
|
r = c.get(path, headers=_auth("ala", "x"))
|
||||||
|
assert r.status_code == 404, f"{path} → {r.status_code}"
|
||||||
|
assert c.get("/nie-ma-takiego-adresu", headers=_auth("ala", "x")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_shows_only_granted_screens(env, monkeypatch):
|
||||||
|
store.create("ala", "x", ["chart", "significators"])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
html = c.get("/", headers=_auth("ala", "x")).text
|
||||||
|
assert 'href="/significators"' in html
|
||||||
|
for absent in ('href="/interpret"', 'href="/timeline"', 'href="/synastry"',
|
||||||
|
'href="/compile"', 'href="/settings"', 'href="/accounts"'):
|
||||||
|
assert absent not in html, f"menu zdradza {absent}"
|
||||||
|
for word in ("Interpretacje", "Kalendarz", "Synastria", "Skompiluj", "Konta"):
|
||||||
|
assert word not in html, f"nazwa „{word}” nie powinna paść"
|
||||||
|
|
||||||
|
|
||||||
|
def test_administrator_sees_everything_including_the_accounts_tab(env, monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
html = c.get("/", headers=_auth("szef", "tajne-szefa")).text
|
||||||
|
for tab in features.SCREENS:
|
||||||
|
assert f'href="{tab.href}"' in html, tab.key
|
||||||
|
assert 'href="/accounts"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_lands_on_the_first_granted_screen(env, monkeypatch):
|
||||||
|
"""Konto bez „Horoskopu" nie może zobaczyć 404 pod adresem głównym —
|
||||||
|
wyglądałoby to na zepsuty program, a nie na węższy zestaw funkcji."""
|
||||||
|
store.create("ala", "x", ["significators"])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
r = c.get("/", headers=_auth("ala", "x"), follow_redirects=False)
|
||||||
|
assert r.status_code == 303 and r.headers["location"] == "/significators"
|
||||||
|
|
||||||
|
|
||||||
|
# ── ograniczenia opcji egzekwowane NA SERWERZE ──────────────────────────
|
||||||
|
|
||||||
|
def test_crafted_request_cannot_buy_options_the_account_lacks(env, monkeypatch):
|
||||||
|
"""Ukrycie pola w formularzu chroni przed przypadkiem, nie przed kimś,
|
||||||
|
kto zna nazwy pól. Granicą jest handler."""
|
||||||
|
store.create("ala", "x", ["chart"])
|
||||||
|
seen = {}
|
||||||
|
from app.main import app, logic
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _chart())[1])
|
||||||
|
r = TestClient(app).post("/", headers=_auth("ala", "x"), data={
|
||||||
|
**FORM, "stations": "true", "tables": "true", "aspect_minor": "true",
|
||||||
|
"zodiac": "sidereal_lahiri", "house_system": "koch",
|
||||||
|
"house_systems": ["koch", "campanus"]})
|
||||||
|
assert r.status_code == 200, r.text[:300]
|
||||||
|
assert seen["stations"] is False and seen["tables"] is False
|
||||||
|
assert seen["aspect_minor"] is False and seen["zodiac"] == "tropical"
|
||||||
|
assert seen["house_system"] == "whole_sign" and seen["house_systems"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_charts_are_not_even_in_the_page_source(env, monkeypatch):
|
||||||
|
"""Nie chodzi o ukrycie rysunków stylem — nie mają w ogóle powstać."""
|
||||||
|
store.create("ala", "x", ["chart"])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
html = c.post("/", headers=_auth("ala", "x"), data=FORM).text
|
||||||
|
for word in ("Aspektarian", "deklinacj", "ntyscj"):
|
||||||
|
assert word not in html, f"źródło strony zdradza „{word}”"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_account_with_the_extra_gets_them(env, monkeypatch):
|
||||||
|
"""Kontrola pozytywna: bez niej powyższy test przechodziłby też wtedy,
|
||||||
|
gdyby rysunki były zepsute dla wszystkich."""
|
||||||
|
store.create("ola", "x", ["chart", "extra_charts"])
|
||||||
|
c = _client(monkeypatch, chart=_chart_with_objects())
|
||||||
|
html = c.post("/", headers=_auth("ola", "x"), data=FORM).text
|
||||||
|
assert "Aspektarian" in html
|
||||||
|
|
||||||
|
|
||||||
|
def _chart_with_objects():
|
||||||
|
"""Horoskop na tyle bogaty, żeby rysunki dodatkowe w ogóle powstały."""
|
||||||
|
base = _chart()
|
||||||
|
base["positions"] = [
|
||||||
|
{"name": n, "glyph": g, "sign": "Aries", "sign_glyph": "♈", "in_sign": "0",
|
||||||
|
"decimal": float(i * 30), "direction": "D", "speed": 1.0,
|
||||||
|
"declination": 10.0, "house": i + 1}
|
||||||
|
for i, (n, g) in enumerate((("Sun", "☉"), ("Moon", "☽"), ("Mars", "♂")))]
|
||||||
|
base["aspects"] = [{"obj1": "Sun", "obj2": "Moon", "aspect": "trine",
|
||||||
|
"orb": 1.0, "allowed": 8.0}]
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_administrator_can_manage_accounts(env, monkeypatch):
|
||||||
|
store.create("ala", "x", ["chart", "ai", "export", "extra_charts"])
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
for method, path in (("get", "/accounts"), ("post", "/accounts/create"),
|
||||||
|
("post", "/accounts/update"), ("post", "/accounts/delete")):
|
||||||
|
kwargs = {"headers": _auth("ala", "x")}
|
||||||
|
if method == "post":
|
||||||
|
kwargs["data"] = {"login": "ktoś"}
|
||||||
|
r = getattr(c, method)(path, **kwargs)
|
||||||
|
assert r.status_code == 404, f"{path} → {r.status_code}"
|
||||||
|
assert c.get("/accounts", headers=_auth("szef", "tajne-szefa")).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── pełny obieg: założenie konta z ekranu i zalogowanie się na nie ───────
|
||||||
|
|
||||||
|
def test_administrator_creates_an_account_and_it_works_immediately(env, monkeypatch):
|
||||||
|
"""Nagłówek całej funkcji: administrator zakłada konto z ekranu, a osoba na
|
||||||
|
tym koncie loguje się i dostaje DOKŁADNIE przyznany zestaw — bez restartu
|
||||||
|
aplikacji i bez dotykania konfiguracji środowiska."""
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
admin = _auth("szef", "tajne-szefa")
|
||||||
|
|
||||||
|
r = c.post("/accounts/create", headers=admin, follow_redirects=False, data={
|
||||||
|
"login": "nowa", "password": "jej-hasło", "note": "praktykantka",
|
||||||
|
"granted": ["chart", "significators", "extra_charts"]})
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
listing = c.get("/accounts", headers=admin).text
|
||||||
|
assert "nowa" in listing and "praktykantka" in listing
|
||||||
|
assert "jej-hasło" not in listing, "hasło nie ma prawa trafić na ekran"
|
||||||
|
|
||||||
|
her = _auth("nowa", "jej-hasło")
|
||||||
|
html = c.get("/", headers=her).text
|
||||||
|
assert 'href="/significators"' in html
|
||||||
|
assert 'href="/accounts"' not in html and 'href="/compile"' not in html
|
||||||
|
assert c.get("/compile", headers=her).status_code == 404
|
||||||
|
|
||||||
|
# odebranie uprawnienia działa od razu
|
||||||
|
c.post("/accounts/update", headers=admin, follow_redirects=False,
|
||||||
|
data={"login": "nowa", "granted": ["chart"]})
|
||||||
|
assert c.get("/significators", headers=her).status_code == 404
|
||||||
|
|
||||||
|
# skasowanie konta odcina logowanie
|
||||||
|
c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"})
|
||||||
|
assert c.get("/", headers=her).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch):
|
||||||
|
"""Konto administracyjne nie leży w pliku, więc nie ma czego skasować —
|
||||||
|
ale próba nie może też wywalić aplikacji ani skasować czegoś innego."""
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
admin = _auth("szef", "tajne-szefa")
|
||||||
|
store.create("ala", "x", ["chart"])
|
||||||
|
r = c.post("/accounts/delete", headers=admin, follow_redirects=False,
|
||||||
|
data={"login": "szef"})
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert store.exists("ala"), "kasowanie nieistniejącego konta ruszyło inne"
|
||||||
|
assert c.get("/", headers=admin).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── awaria magazynu kont ─────────────────────────────────────────────────
|
||||||
|
# Ekran kont to JEDYNE miejsce, z którego administrator może naprawić problem
|
||||||
|
# z magazynem — więc musi na nim przeczytać, co i gdzie jest nie tak. Gołe 500
|
||||||
|
# (tak było na pierwszym wdrożeniu) zostawia go z niczym.
|
||||||
|
|
||||||
|
def _unreadable(tmp_path, monkeypatch, mode):
|
||||||
|
d = tmp_path / "stan"
|
||||||
|
d.mkdir()
|
||||||
|
monkeypatch.setenv("ACCOUNTS_FILE", str(d / "accounts.json"))
|
||||||
|
d.chmod(mode)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_store_explains_itself_instead_of_500(env, tmp_path, monkeypatch):
|
||||||
|
d = _unreadable(tmp_path, monkeypatch, 0o000)
|
||||||
|
try:
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
|
||||||
|
assert r.status_code == 200, "problem z magazynem nie może wywalać strony"
|
||||||
|
assert "Nie mogę odczytać pliku kont" in r.text
|
||||||
|
assert str(d / "accounts.json") in r.text, "komunikat ma podać ŚCIEŻKĘ"
|
||||||
|
finally:
|
||||||
|
d.chmod(0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_only_store_refuses_to_save_with_a_reason(env, tmp_path, monkeypatch):
|
||||||
|
d = _unreadable(tmp_path, monkeypatch, 0o555)
|
||||||
|
try:
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
r = c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
|
||||||
|
follow_redirects=False,
|
||||||
|
data={"login": "ala", "password": "x", "granted": ["chart"]})
|
||||||
|
assert r.status_code == 303
|
||||||
|
# Komunikat jedzie w parametrze zapytania, więc jest zakodowany —
|
||||||
|
# porównanie na surowym nagłówku sprawdzałoby procenty, nie treść.
|
||||||
|
from urllib.parse import unquote_plus
|
||||||
|
|
||||||
|
assert "Nie mogę zapisać pliku kont" in unquote_plus(r.headers["location"])
|
||||||
|
finally:
|
||||||
|
d.chmod(0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_corrupt_file_is_never_silently_overwritten(env, tmp_path, monkeypatch):
|
||||||
|
"""Nadpisanie uszkodzonego pliku pustym zbiorem skasowałoby WSZYSTKIE konta.
|
||||||
|
Lepiej odmówić i powiedzieć, co jest nie tak."""
|
||||||
|
path = tmp_path / "accounts.json"
|
||||||
|
path.write_text("{to nie jest json", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("ACCOUNTS_FILE", str(path))
|
||||||
|
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
|
||||||
|
assert r.status_code == 200 and "uszkodzony" in r.text
|
||||||
|
|
||||||
|
c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
|
||||||
|
follow_redirects=False, data={"login": "ala", "password": "x"})
|
||||||
|
assert path.read_text(encoding="utf-8") == "{to nie jest json", \
|
||||||
|
"uszkodzony plik został nadpisany — konta by zniknęły"
|
||||||
@@ -9,7 +9,16 @@ CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
|
|||||||
|
|
||||||
|
|
||||||
def test_tab_is_in_the_menu():
|
def test_tab_is_in_the_menu():
|
||||||
assert 'href="/settings"' in BASE and "Ustawienia" in BASE
|
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
|
||||||
|
|
||||||
|
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
|
||||||
|
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
|
||||||
|
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
|
||||||
|
from app import features
|
||||||
|
|
||||||
|
tab = features.BY_KEY["settings"]
|
||||||
|
assert tab in features.SCREENS
|
||||||
|
assert tab.href and tab.label
|
||||||
|
|
||||||
|
|
||||||
def test_route_and_client_exist():
|
def test_route_and_client_exist():
|
||||||
|
|||||||
@@ -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
|
||||||
+19
-2
@@ -14,8 +14,25 @@ tysiące porównań na całej dziedzinie tak.
|
|||||||
**Izolacja jednostki.** Obu implementacjom podajemy TE SAME wejścia przez
|
**Izolacja jednostki.** Obu implementacjom podajemy TE SAME wejścia przez
|
||||||
`swe_houses_armc`. Gdybyśmy porównywali „nasz horoskop" z „horoskopem swissepha",
|
`swe_houses_armc`. Gdybyśmy porównywali „nasz horoskop" z „horoskopem swissepha",
|
||||||
różnica mogłaby wynikać z innego czasu gwiazdowego albo innego ε — czyli z czegoś,
|
różnica mogłaby wynikać z innego czasu gwiazdowego albo innego ε — czyli z czegoś,
|
||||||
co nie jest testowaną funkcją, i utonęlibyśmy w fałszywych alarmach. Czas gwiazdowy
|
co nie jest testowaną funkcją, i utonęlibyśmy w fałszywych alarmach.
|
||||||
i ε mają własny, osobny test (`asc/mc`).
|
|
||||||
|
**Czego ten framework NIE sprawdza — i trzeba o tym pamiętać.** Skoro ε podajemy
|
||||||
|
obu stronom, to **samo ε nie jest tu testowane**. Test `asc/mc` sprawdza wyłącznie
|
||||||
|
wzory na Ascendent i MC przy zadanym ε, a nie to, czy ε jest właściwe. Kosztowało
|
||||||
|
to realny błąd: silnik liczył RAMC z GAST (czas gwiazdowy POZORNY, od równonocy
|
||||||
|
PRAWDZIWEJ), ale parował go z ε ŚREDNIM, bez nutacji. Dwa różne układy odniesienia,
|
||||||
|
~3,2″ przesunięcia na cuspach — i komplet zielonych przebiegów wyroczni, bo błąd
|
||||||
|
siedział w danych WEJŚCIOWYCH, nie w testowanej funkcji.
|
||||||
|
|
||||||
|
Wejście ma więc własny sprawdzian, poza tym frameworkiem:
|
||||||
|
`test_sidereal_returns_true_obliquity_not_mean` w `services/logic/tests/`, ze
|
||||||
|
Skyfieldem jako niezależnym autorytetem (bez swissepha, więc działa wszędzie).
|
||||||
|
|
||||||
|
Uwaga pokrewna: porównanie „cały horoskop nasz vs cały horoskop swissepha" zostawia
|
||||||
|
resztę ~3″ nawet po naprawie ε — to UT1 kontra UTC. Skyfield konwertuje UTC na UT1
|
||||||
|
z tablic IERS, swisseph przyjmuje podany JD jako UT1 (w 1984-04-30 różnica wynosiła
|
||||||
|
0,181 s = 2,7″ RAMC). Nasza strona jest tu dokładniejsza. Dokładnie po to porównujemy
|
||||||
|
przy identycznym RAMC.
|
||||||
|
|
||||||
**Kryterium, nie procent.** Raportujemy liczbę przypadków **powyżej tolerancji
|
**Kryterium, nie procent.** Raportujemy liczbę przypadków **powyżej tolerancji
|
||||||
(1″)**, **maksymalne odchylenie** i **miejsce** jego wystąpienia oraz histogram.
|
(1″)**, **maksymalne odchylenie** i **miejsce** jego wystąpienia oraz histogram.
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ JD_J2000 = 2_451_545.0 # punkt odniesienia — środek dziedziny
|
|||||||
# Systemy oparte na łuku dobowym: tracą definicję za kołem podbiegunowym.
|
# Systemy oparte na łuku dobowym: tracą definicję za kołem podbiegunowym.
|
||||||
DIURNAL_ARC_SYSTEMS = frozenset({"placidus", "koch"})
|
DIURNAL_ARC_SYSTEMS = frozenset({"placidus", "koch"})
|
||||||
|
|
||||||
|
# Systemy, w których NASZA dziedzina jest WĘŻSZA niż wyroczni — świadomie, nie przez
|
||||||
|
# rozjazd. Topocentric: swisseph zwraca powyżej koła podbiegunowego cuspy, które nie
|
||||||
|
# dzielą okręgu (domy nachodzą na siebie), i sam sobie tam przeczy — swe_house_pos
|
||||||
|
# nie zgadza się z jego własnymi cuspami. Wolimy odmówić niż podać liczbę bez
|
||||||
|
# znaczenia, więc harness ma tego NIE zgłaszać jako niezgodności dziedziny.
|
||||||
|
NARROWER_THAN_ORACLE = frozenset({"topocentric"})
|
||||||
|
|
||||||
# Jak blisko granicy stawiamy wartości brzegowe (po obu stronach).
|
# Jak blisko granicy stawiamy wartości brzegowe (po obu stronach).
|
||||||
EPSILON_NUDGE = 0.01
|
EPSILON_NUDGE = 0.01
|
||||||
|
|
||||||
@@ -65,7 +72,7 @@ def in_domain(system: str, lat: float, jd: float) -> bool:
|
|||||||
(i same bieguny, gdzie degeneruje się cała geometria)."""
|
(i same bieguny, gdzie degeneruje się cała geometria)."""
|
||||||
if abs(lat) >= 90.0:
|
if abs(lat) >= 90.0:
|
||||||
return False
|
return False
|
||||||
if system in DIURNAL_ARC_SYSTEMS:
|
if system in DIURNAL_ARC_SYSTEMS or system in NARROWER_THAN_ORACLE:
|
||||||
return abs(lat) < polar_circle(jd)
|
return abs(lat) < polar_circle(jd)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
+61
-3
@@ -16,11 +16,12 @@ do ścieżki produkcyjnej ani do zależności produktu — izolacja z LOG-27 zos
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
import swisseph as swe
|
import swisseph as swe
|
||||||
|
|
||||||
from domain import Case, in_domain, obliquity
|
from domain import NARROWER_THAN_ORACLE, Case, in_domain, obliquity
|
||||||
|
|
||||||
# Litery systemów w Swiss Ephemeris.
|
# Litery systemów w Swiss Ephemeris.
|
||||||
SWE_CODE = {
|
SWE_CODE = {
|
||||||
@@ -110,12 +111,16 @@ def compare_system(system: str, cases: list[Case]) -> Result:
|
|||||||
|
|
||||||
# Zgodność co do DZIEDZINY: jeśli wyrocznia odmawia, a my uważamy, że
|
# Zgodność co do DZIEDZINY: jeśli wyrocznia odmawia, a my uważamy, że
|
||||||
# liczymy — nasza detekcja granicy jest zepsuta (albo odwrotnie).
|
# liczymy — nasza detekcja granicy jest zepsuta (albo odwrotnie).
|
||||||
if (theirs is None) != (not ours_in_domain):
|
# Węższa dziedzina po naszej stronie jest świadoma (patrz NARROWER_THAN_ORACLE),
|
||||||
|
# więc nie jest rozjazdem. Odwrotność — my liczymy, wyrocznia odmawia — nadal jest.
|
||||||
|
narrower_on_purpose = (system in NARROWER_THAN_ORACLE
|
||||||
|
and theirs is not None and not ours_in_domain)
|
||||||
|
if not narrower_on_purpose and (theirs is None) != (not ours_in_domain):
|
||||||
r.domain_mismatch.append(
|
r.domain_mismatch.append(
|
||||||
f"{c.key()} [{c.label}] — my:{'w dziedzinie' if ours_in_domain else 'poza'}, "
|
f"{c.key()} [{c.label}] — my:{'w dziedzinie' if ours_in_domain else 'poza'}, "
|
||||||
f"wyrocznia:{'odmawia' if theirs is None else 'liczy'}")
|
f"wyrocznia:{'odmawia' if theirs is None else 'liczy'}")
|
||||||
continue
|
continue
|
||||||
if theirs is None:
|
if theirs is None or not ours_in_domain:
|
||||||
r.skipped_out_of_domain += 1
|
r.skipped_out_of_domain += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -155,6 +160,59 @@ def histogram(devs: list[float]) -> str:
|
|||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
# Ile punktów ekliptyki sprawdzamy na jeden zestaw (RAMC, ε, φ).
|
||||||
|
_PROBES_PER_CASE = 6
|
||||||
|
|
||||||
|
|
||||||
|
def compare_house_assignment(system: str, cases: list[Case]) -> Result:
|
||||||
|
"""Nasze przypisanie obiektu do domu vs swe_house_pos.
|
||||||
|
|
||||||
|
To OSOBNA funkcja od liczenia cuspów i osobny rodzaj błędu: cuspy mogą się
|
||||||
|
zgadzać co do zera, a planeta i tak wylądować w złym domu. Tak właśnie było —
|
||||||
|
przy |φ| powyżej koła podbiegunowego systemy dzielące koła wielkie mają
|
||||||
|
kolejność domów ODWRÓCONĄ, a nasze assign_house szło zawsze do przodu.
|
||||||
|
|
||||||
|
WYROCZNIA MA TU GRANICĘ WIARYGODNOŚCI. Dla topocentrica swe_house_pos
|
||||||
|
przestaje być zgodne z WŁASNYMI cuspami swissepha powyżej koła podbiegunowego
|
||||||
|
(zmierzone: 100% zgodności do 62°, 83,9% przy 66°, ok. 50% przy 72°; dla
|
||||||
|
regiomontanusa w tych samych punktach 100%). Skoro wyrocznia przeczy sama
|
||||||
|
sobie, nie może rozstrzygać — nie porównujemy tam, zamiast dopisywać wyjątek
|
||||||
|
„bo topocentric". Nasze przypisanie jest w tym obszarze sprawdzane inaczej:
|
||||||
|
testem samospójności z cuspami (services/logic/tests/test_houses.py).
|
||||||
|
"""
|
||||||
|
from app.engine.houses import assign_house, cusps_for, polar_circle
|
||||||
|
|
||||||
|
r = Result(system=f"dom:{system}")
|
||||||
|
rng = random.Random(20260806)
|
||||||
|
for c in cases:
|
||||||
|
eps = obliquity(c.jd)
|
||||||
|
if system == "topocentric" and abs(c.lat) >= polar_circle(eps):
|
||||||
|
r.skipped_out_of_domain += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ours_cusps = cusps_for(c.ramc, eps, c.lat, system)
|
||||||
|
except Exception:
|
||||||
|
r.skipped_out_of_domain += 1
|
||||||
|
continue
|
||||||
|
for _ in range(_PROBES_PER_CASE):
|
||||||
|
lon = rng.uniform(0.0, 360.0)
|
||||||
|
try:
|
||||||
|
theirs = int(swe.house_pos(c.ramc % 360.0, c.lat, eps,
|
||||||
|
(lon, 0.0), SWE_CODE[system]))
|
||||||
|
except Exception:
|
||||||
|
r.skipped_out_of_domain += 1
|
||||||
|
continue
|
||||||
|
r.checked += 1
|
||||||
|
ours = assign_house(lon, ours_cusps)
|
||||||
|
if ours != theirs:
|
||||||
|
r.over_tolerance += 1
|
||||||
|
r.max_dev = max(r.max_dev, float(abs(ours - theirs)))
|
||||||
|
if len(r.domain_mismatch) < 5:
|
||||||
|
r.domain_mismatch.append(
|
||||||
|
f"{c.key()} lon={lon:.4f} — nasz dom {ours}, wyrocznia {theirs}")
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
def format_report(results: list[Result], seed: int | None = None) -> str:
|
def format_report(results: list[Result], seed: int | None = None) -> str:
|
||||||
lines = ["", "=" * 78,
|
lines = ["", "=" * 78,
|
||||||
"ZGODNOŚĆ Z WYROCZNIĄ (Swiss Ephemeris) — domy astrologiczne",
|
"ZGODNOŚĆ Z WYROCZNIĄ (Swiss Ephemeris) — domy astrologiczne",
|
||||||
|
|||||||
+4
-1
@@ -22,7 +22,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||||||
|
|
||||||
from domain import boundary_cases, interior_cases, random_cases # noqa: E402
|
from domain import boundary_cases, interior_cases, random_cases # noqa: E402
|
||||||
from harness import ( # noqa: E402
|
from harness import ( # noqa: E402
|
||||||
SWE_CODE, compare_asc_mc, compare_system, format_report,
|
SWE_CODE, compare_asc_mc, compare_house_assignment, compare_system,
|
||||||
|
format_report,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) —
|
# Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) —
|
||||||
@@ -57,6 +58,8 @@ def main() -> int:
|
|||||||
|
|
||||||
results = [compare_asc_mc(cases)]
|
results = [compare_asc_mc(cases)]
|
||||||
results += [compare_system(s, cases) for s in systems]
|
results += [compare_system(s, cases) for s in systems]
|
||||||
|
# Przypisanie obiektu do domu — osobny rodzaj błędu niż same cuspy.
|
||||||
|
results += [compare_house_assignment(s, cases) for s in systems]
|
||||||
|
|
||||||
print(f"\nprzypadków wejściowych: {len(cases)} (tryb: {args.mode})")
|
print(f"\nprzypadków wejściowych: {len(cases)} (tryb: {args.mode})")
|
||||||
print(format_report(results, seed=seed_note))
|
print(format_report(results, seed=seed_note))
|
||||||
|
|||||||
Reference in New Issue
Block a user