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