Merge pull request #1 from migatu/scaffold/three-tier-app

Szkielet aplikacji trójwarstwowej (prezentacja / logika / dane)
This commit is contained in:
2026-06-26 17:36:22 +02:00
committed by GitHub
52 changed files with 1491 additions and 1 deletions
+17
View File
@@ -0,0 +1,17 @@
# Skopiuj do .env i dostosuj.
# --- warstwa bazodanowa ---
DATA_PROVIDER=excel # excel | sql
EXCEL_DIR=./services/data/data_files
CACHE_DIR=./services/data/.cache
INDEXED_KEYS=name,id,symbol
HEADER_SCAN_ROWS=15
QUERY_CACHE_SIZE=512
QUERY_CACHE_TTL=300
SQL_URL=sqlite:///./.cache/astrololo.db
# --- warstwa logiczna ---
DATA_URL=http://localhost:8002
# --- warstwa prezentacji ---
LOGIC_URL=http://localhost:8001
+21
View File
@@ -0,0 +1,21 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
# Cache warstwy bazodanowej (regenerowalny)
services/data/.cache/
*.parquet
*.db
# Dane wejściowe (duże pliki Excela trzymane poza repo)
services/data/data_files/*.xlsx
!services/data/data_files/.gitkeep
# Narzędzia
.env
.DS_Store
.idea/
.vscode/
+38
View File
@@ -0,0 +1,38 @@
.PHONY: help up down sample reindex migrate sql dev-data dev-logic dev-presentation
help:
@echo "up - uruchom wszystkie 3 warstwy (docker compose)"
@echo "down - zatrzymaj"
@echo "sample - wygeneruj przykładowe pliki .xlsx"
@echo "reindex - zbuduj cache + indeks warstwy bazodanowej"
@echo "migrate - ETL: Excel -> SQL"
@echo "sql - uruchom z warstwą SQL (DATA_PROVIDER=sql)"
@echo "dev-* - uruchom pojedynczą warstwę lokalnie (bez dockera)"
up:
docker compose up --build
down:
docker compose down
sample:
cd services/data && python scripts/make_sample_data.py
reindex:
cd services/data && python -m app.ingest.build_index
migrate:
cd services/data && python -m app.ingest.to_sql
sql:
DATA_PROVIDER=sql docker compose up --build
# --- lokalny dev (3 osobne terminale) ---
dev-data:
cd services/data && uvicorn app.main:app --reload --port 8002
dev-logic:
cd services/logic && DATA_URL=http://localhost:8002 uvicorn app.main:app --reload --port 8001
dev-presentation:
cd services/presentation && LOGIC_URL=http://localhost:8001 uvicorn app.main:app --reload --port 8000
+68 -1
View File
@@ -1 +1,68 @@
# astrololo
# astrololo
Aplikacja w **modelu trójwarstwowym**, w pełni modułowa: trzy niezależne usługi,
każda komunikuje się wyłącznie z sąsiadem (nigdy „przez głowę”).
```
┌──────────────────────┐ formularz (w dół) ┌──────────────────────┐ zapytanie (w dół) ┌──────────────────────┐
│ PREZENTACJA (:8000) │ ───────────────────▶ │ LOGICZNA (:8001) │ ───────────────────▶ │ BAZODANOWA (:8002) │
│ strona WWW + form │ ◀─────────────────── │ reguły biznesowe │ ◀─────────────────── │ wyszukiwanie danych │
└──────────────────────┘ wyniki (w górę) └──────────────────────┘ dane (w górę) └──────────────────────┘
HTML/UI pośrednik + logika Excel(+cache) ▸ SQL
```
Każda warstwa to osobny katalog, osobny `requirements.txt`, osobny `Dockerfile`
i osobne README. Komunikacja przez HTTP/JSON. Warstwa zna **tylko adres warstwy
bezpośrednio pod nią** — nic o jej wnętrzu.
| Warstwa | Katalog | Zna w dół | Zadanie |
|--------|---------|-----------|---------|
| Prezentacji | [`services/presentation`](services/presentation) | `LOGIC_URL` | serwuje stronę, przekazuje formularz, renderuje wyniki |
| Logiczna | [`services/logic`](services/logic) | `DATA_URL` | reguły biznesowe, tłumaczenie zapytań, opracowanie wyników |
| Bazodanowa | [`services/data`](services/data) | pliki Excela / SQL | **tylko** wyszukiwanie danych i podanie ich w górę |
## Szybki start (Docker)
```bash
make sample # przykładowe pliki .xlsx do warstwy bazodanowej
make up # zbuduj i uruchom 3 warstwy
# otwórz http://localhost:8000
```
## Szybki start (lokalnie, 3 terminale)
```bash
cd services/data && pip install -r requirements.txt && python scripts/make_sample_data.py
make dev-data # terminal 1 -> :8002
make dev-logic # terminal 2 -> :8001
make dev-presentation # terminal 3 -> :8000
```
## Modułowość — dowód
- Wymień prezentację (np. na SPA/React) → reszta bez zmian, kontrakt `/api/query` stały.
- Wymień bazę (Excel → SQL) → prezentacja i logika bez zmian (patrz niżej).
- Każdą warstwę da się uruchomić, testować i wdrażać osobno.
## Wydajność warstwy Excela — cache 4-poziomowy
Dziś dane to setki dużych `.xlsx`, przeszukiwanych po **wykrytym nagłówku** i
**układzie kolumn**. To kosztowne, więc warstwa bazodanowa ma cache (szczegóły:
[`services/data/README.md`](services/data/README.md)):
1. **Schemat (L1, SQLite)** — wykryty nagłówek + mapowanie kolumn zapisane raz na wersję pliku.
2. **Dane (L2, Parquet)** — znormalizowany arkusz; kolejne odczyty 10100× szybsze niż `.xlsx`.
3. **Zapytania (L3, in-memory TTL/LRU)** — powtarzalne wyszukiwania natychmiast (łatwo podmienić na Redis).
4. **Odwrócony indeks (L4, SQLite)**`wartość → plik`; otwieramy tylko trafione pliki zamiast skanu setek.
Unieważnianie automatyczne: klucz cache = **odcisk pliku** (`mtime+rozmiar`,
opcjonalnie `sha256`). Zmiana pliku → przebudowa tylko jego wpisów.
## Droga na przyszłość — migracja do SQL
Warstwa bazodanowa ukrywa źródło za interfejsem `DataProvider` (wzorzec
Repository). Migracja:
```bash
make migrate # ETL: tym samym loaderem Excel -> tabela 'records' + indeksy
export DATA_PROVIDER=sql # przełącz całą warstwę
```
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
+34
View File
@@ -0,0 +1,34 @@
services:
data:
build: ./services/data
environment:
DATA_PROVIDER: ${DATA_PROVIDER:-excel}
EXCEL_DIR: /app/data_files
CACHE_DIR: /app/.cache
INDEXED_KEYS: name,id,symbol
volumes:
- ./services/data/data_files:/app/data_files
- data_cache:/app/.cache
ports:
- "8002:8002"
logic:
build: ./services/logic
environment:
DATA_URL: http://data:8002
depends_on:
- data
ports:
- "8001:8001"
presentation:
build: ./services/presentation
environment:
LOGIC_URL: http://logic:8001
depends_on:
- logic
ports:
- "8000:8000"
volumes:
data_cache:
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8002
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"]
+46
View File
@@ -0,0 +1,46 @@
# Warstwa bazodanowa (`data`)
Niezależna usługa. **Jedyne zadanie:** wyszukać dane i podać je w górę. Nie zna
warstwy logicznej ani prezentacji — komunikacja wyłącznie przez HTTP/JSON
(`models.py`).
## API
- `POST /search``SearchQuery``SearchResult`
- `GET /health``HealthInfo`
## Architektura wewnętrzna
```
providers/ wymienna implementacja (wzorzec Repository)
base.py interfejs DataProvider ← kontrakt
excel_provider dziś: Excel + 4 poziomy cache
sql_provider jutro: SQL (ten sam interfejs)
factory.py DATA_PROVIDER=excel|sql
excel/ wykrywanie nagłówka + mapowanie układu kolumn (jedyne miejsce znające .xlsx)
cache/ fingerprint, schema(L1), frame/parquet(L2), query(L3), index(L4)
ingest/ build_index.py (warmup), to_sql.py (migracja ETL)
```
## Cache — dlaczego szybko
| Poziom | Co cache'uje | Zysk |
|-------|---------------|------|
| L1 `schema.db` | wykryty nagłówek + układ kolumn per plik | brak ponownego skanu heurystyką |
| L2 Parquet | znormalizowany arkusz | 10100× szybciej niż parsowanie `.xlsx` |
| L3 `QueryCache` | wynik zapytania (TTL/LRU) | powtarzalne zapytania natychmiast |
| L4 `index.db` | odwrócony indeks wartość→plik | otwieramy tylko trafione pliki, nie setki |
Unieważnianie: klucz = odcisk pliku (`mtime+rozmiar`, opcjonalnie `sha256`).
Zmiana pliku → inny odcisk → automatyczny przebudowa.
## Uruchomienie lokalne
```bash
pip install -r requirements.txt
python scripts/make_sample_data.py # przykładowe .xlsx
python -m app.ingest.build_index # (opcjonalnie) prebuild indeksu
uvicorn app.main:app --port 8002
```
## Migracja do SQL (gdy nadejdzie czas)
```bash
python -m app.ingest.to_sql # Excel -> tabela 'records' + indeksy
export DATA_PROVIDER=sql # przełącz warstwę — reszta systemu bez zmian
```
View File
View File
+22
View File
@@ -0,0 +1,22 @@
"""Odcisk pliku = klucz unieważniania cache.
Wszystkie warstwy cache są kluczowane odciskiem pliku. Gdy plik Excela się zmieni,
zmienia się odcisk -> automatyczny "cache miss" i przebudowa. Domyślnie używamy
taniego (mtime + rozmiar); sha256 dostępne, gdy potrzeba pewności co do treści.
"""
from __future__ import annotations
import hashlib
import os
def fingerprint(path: str, strong: bool = False) -> str:
if strong:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return "sha256:" + h.hexdigest()[:16]
st = os.stat(path)
return f"mt:{int(st.st_mtime)}:{st.st_size}"
+35
View File
@@ -0,0 +1,35 @@
"""Cache danych: znormalizowany arkusz zapisany jako Parquet.
POZIOM 2 cache — największy zysk wydajności. Parsowanie .xlsx jest wolne
(dziesiątkisetki ms na duży plik). Po pierwszym wczytaniu zapisujemy
znormalizowaną ramkę jako Parquet (kolumnowy, kompresowany), kluczowaną odciskiem
pliku. Kolejne odczyty ładują Parquet — zwykle 10100x szybciej niż .xlsx i bez
ponownego wykrywania nagłówka.
Plik Parquet jest też naturalnym formatem pośrednim przy migracji do SQL.
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
class FrameCache:
def __init__(self, cache_dir: Path) -> None:
self.dir = cache_dir / "frames"
self.dir.mkdir(parents=True, exist_ok=True)
def _path(self, fp: str, sheet: str) -> Path:
safe = fp.replace(":", "_") + "__" + str(sheet).replace("/", "_")
return self.dir / f"{safe}.parquet"
def get(self, fp: str, sheet: str) -> pd.DataFrame | None:
p = self._path(fp, sheet)
if p.exists():
return pd.read_parquet(p)
return None
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
# astype(str) na kolumnach object zapewnia stabilny zapis Parquet
frame.to_parquet(self._path(fp, sheet), index=False)
+68
View File
@@ -0,0 +1,68 @@
"""Odwrócony indeks: wartość kanonicznego klucza -> które pliki/arkusze ją mają.
POZIOM 4 (i najważniejszy przy skali) — pozwala NIE skanować setek plików przy
każdym zapytaniu. Budujemy w SQLite indeks: dla wybranych kluczy (np. name, id,
symbol) zapisujemy, w którym pliku/arkuszu występuje dana wartość. Wyszukiwanie
najpierw pyta indeks (jeden szybki SELECT), a otwiera tylko trafione pliki.
Ten SQLite indeks jest jednocześnie POMOSTEM do pełnej migracji SQL — rozbudowa
go o wszystkie kolumny = de facto baza danych (patrz ingest/to_sql.py).
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
class InvertedIndex:
def __init__(self, cache_dir: Path) -> None:
self._db = sqlite3.connect(str(cache_dir / "index.db"), check_same_thread=False)
self._db.execute(
"""
CREATE TABLE IF NOT EXISTS entries (
key TEXT, -- kanoniczne pole, np. 'name'
value TEXT, -- znormalizowana (lower) wartość
file TEXT, -- ścieżka pliku
sheet TEXT
)
"""
)
self._db.execute("CREATE INDEX IF NOT EXISTS ix_kv ON entries(key, value)")
self._db.execute(
"CREATE TABLE IF NOT EXISTS files (file TEXT PRIMARY KEY, fingerprint TEXT)"
)
self._db.commit()
def file_fingerprint(self, file: str) -> str | None:
cur = self._db.execute("SELECT fingerprint FROM files WHERE file=?", (file,))
row = cur.fetchone()
return row[0] if row else None
def reindex_file(self, file: str, fingerprint: str, rows: list[tuple[str, str, str]]) -> None:
"""rows: lista (key, value, sheet) dla jednego pliku."""
self._db.execute("DELETE FROM entries WHERE file=?", (file,))
self._db.executemany(
"INSERT INTO entries(key, value, file, sheet) VALUES (?,?,?,?)",
[(k, v.lower(), file, sheet) for (k, v, sheet) in rows],
)
self._db.execute(
"INSERT OR REPLACE INTO files(file, fingerprint) VALUES (?,?)", (file, fingerprint)
)
self._db.commit()
def lookup(self, key: str, value: str, exact: bool) -> list[tuple[str, str]]:
"""Zwraca listę (file, sheet) kandydatów do przeszukania."""
if exact:
cur = self._db.execute(
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value=?",
(key, value.lower()),
)
else:
cur = self._db.execute(
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value LIKE ?",
(key, f"%{value.lower()}%"),
)
return [(r[0], r[1]) for r in cur.fetchall()]
def count_files(self) -> int:
return self._db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
+39
View File
@@ -0,0 +1,39 @@
"""Cache wyników zapytań: in-memory LRU + TTL.
POZIOM 3 cache. Te same zapytania powtarzają się (popularne wyszukiwania). Tu
trzymamy gotowy wynik przez krótki TTL. Bez zewnętrznych zależności — w produkcji
można podmienić na Redis (ten sam interfejs get/put), by współdzielić cache
między instancjami.
"""
from __future__ import annotations
import time
from collections import OrderedDict
from typing import Any
class QueryCache:
def __init__(self, max_size: int = 512, ttl: int = 300) -> None:
self.max_size = max_size
self.ttl = ttl
self._store: OrderedDict[str, tuple[float, Any]] = OrderedDict()
def get(self, key: str) -> Any | None:
item = self._store.get(key)
if item is None:
return None
ts, value = item
if time.time() - ts > self.ttl:
del self._store[key]
return None
self._store.move_to_end(key)
return value
def put(self, key: str, value: Any) -> None:
self._store[key] = (time.time(), value)
self._store.move_to_end(key)
while len(self._store) > self.max_size:
self._store.popitem(last=False)
def clear(self) -> None:
self._store.clear()
+45
View File
@@ -0,0 +1,45 @@
"""Cache schematu: wykryty wiersz nagłówka + mapowanie kolumn, per (plik, arkusz).
POZIOM 1 cache. Najdroższe jest samo wykrywanie nagłówka i wnioskowanie układu
kolumn. Robimy to RAZ na wersję pliku i zapisujemy w SQLite. Kolejne odczyty
pomijają skanowanie.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
class SchemaCache:
def __init__(self, cache_dir: Path) -> None:
self._db = sqlite3.connect(str(cache_dir / "schema.db"), check_same_thread=False)
self._db.execute(
"""
CREATE TABLE IF NOT EXISTS schema_cache (
fingerprint TEXT,
sheet TEXT,
header_row INTEGER,
mapping TEXT,
PRIMARY KEY (fingerprint, sheet)
)
"""
)
self._db.commit()
def get(self, fp: str, sheet: str) -> tuple[int, dict[str, str]] | None:
cur = self._db.execute(
"SELECT header_row, mapping FROM schema_cache WHERE fingerprint=? AND sheet=?",
(fp, sheet),
)
row = cur.fetchone()
if row is None:
return None
return int(row[0]), json.loads(row[1])
def put(self, fp: str, sheet: str, header_row: int, mapping: dict[str, str]) -> None:
self._db.execute(
"INSERT OR REPLACE INTO schema_cache VALUES (?,?,?,?)",
(fp, sheet, header_row, json.dumps(mapping, ensure_ascii=False)),
)
self._db.commit()
+49
View File
@@ -0,0 +1,49 @@
"""Konfiguracja warstwy bazodanowej (z ENV).
Najważniejsza zmienna: DATA_PROVIDER. Zmiana 'excel' -> 'sql' przełącza całą
warstwę na bazę SQL bez dotykania pozostałych modułów (patrz providers/factory.py).
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent # .../services/data
@dataclass
class Settings:
# 'excel' (dziś) albo 'sql' (po migracji)
provider: str = field(default_factory=lambda: os.getenv("DATA_PROVIDER", "excel"))
# Warstwa Excel
excel_dir: Path = field(
default_factory=lambda: Path(os.getenv("EXCEL_DIR", str(BASE_DIR / "data_files")))
)
cache_dir: Path = field(
default_factory=lambda: Path(os.getenv("CACHE_DIR", str(BASE_DIR / ".cache")))
)
header_scan_rows: int = field(default_factory=lambda: int(os.getenv("HEADER_SCAN_ROWS", "15")))
# Klucze kanoniczne, które trafiają do odwróconego indeksu (przyspiesza wyszukiwanie).
indexed_keys: tuple[str, ...] = field(
default_factory=lambda: tuple(
k.strip() for k in os.getenv("INDEXED_KEYS", "name,id,symbol").split(",") if k.strip()
)
)
# Cache zapytań (in-memory)
query_cache_size: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_SIZE", "512")))
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
# Warstwa SQL (po migracji)
sql_url: str = field(
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
)
def __post_init__(self) -> None:
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.excel_dir.mkdir(parents=True, exist_ok=True)
settings = Settings()
View File
+52
View File
@@ -0,0 +1,52 @@
"""Wykrywanie wiersza nagłówka w arkuszu.
Pliki Excela w tej domenie nie mają nagłówka zawsze w pierwszym wierszu — bywają
puste wiersze, tytuły, metadane. Ta heurystyka skanuje pierwsze N wierszy i
wybiera ten, który "wygląda jak nagłówek": dużo niepustych, tekstowych,
unikalnych komórek, po którym następują wiersze danych o podobnym wypełnieniu.
Wynik (indeks wiersza nagłówka) jest CACHE'OWANY per plik (schema_cache), więc
ten kosztowny skan robimy raz na wersję pliku.
"""
from __future__ import annotations
import pandas as pd
def _row_score(raw: pd.DataFrame, i: int) -> float:
row = raw.iloc[i]
non_null = row.notna()
filled = float(non_null.mean()) if len(row) else 0.0
if filled == 0:
return -1.0
values = [str(v).strip() for v in row[non_null].tolist()]
text_like = sum(1 for v in values if v and not _looks_numeric(v))
text_ratio = text_like / max(len(values), 1)
uniqueness = len(set(values)) / max(len(values), 1)
# Wiersze danych pod spodem powinny mieć podobną liczbę wypełnionych kolumn.
follow_bonus = 0.0
if i + 1 < len(raw):
below = raw.iloc[i + 1].notna().mean()
follow_bonus = 1.0 - abs(filled - float(below))
return filled * 0.4 + text_ratio * 0.3 + uniqueness * 0.2 + follow_bonus * 0.1
def _looks_numeric(v: str) -> bool:
try:
float(v.replace(",", "."))
return True
except ValueError:
return False
def detect_header_row(raw: pd.DataFrame, max_scan: int = 15) -> int:
"""Zwraca indeks (0-based) wiersza, który najprawdopodobniej jest nagłówkiem."""
best_idx, best_score = 0, float("-inf")
for i in range(min(max_scan, len(raw))):
score = _row_score(raw, i)
if score > best_score:
best_idx, best_score = i, score
return best_idx
+52
View File
@@ -0,0 +1,52 @@
"""Mapowanie układu kolumn na schemat kanoniczny.
Setki plików mogą mieć te same dane pod różnymi nagłówkami i w różnej kolejności
kolumn ("Imię", "Name", "NAZWA" -> kanoniczne 'name'). Ta warstwa tłumaczy
faktyczny układ kolumn pliku na wspólny słownik pól, dzięki czemu reszta systemu
(i przyszła baza SQL) operuje na jednej, stabilnej nazwie pola.
Aliasowanie jest świadomie wydzielone i konfigurowalne — to jedyne miejsce do
edycji, gdy pojawi się nowy wariant nagłówka.
"""
from __future__ import annotations
import re
# kanoniczne_pole -> zbiór aliasów (po normalizacji)
CANONICAL_ALIASES: dict[str, set[str]] = {
"id": {"id", "identyfikator", "nr", "no", "number"},
"name": {"name", "imie", "nazwa", "nazwisko", "title", "tytul"},
"symbol": {"symbol", "znak", "sign", "glyph"},
"date": {"date", "data", "datetime", "timestamp"},
"value": {"value", "wartosc", "val", "amount", "kwota"},
"category": {"category", "kategoria", "type", "typ", "group", "grupa"},
}
def _normalize(col: str) -> str:
s = str(col).strip().lower()
s = re.sub(r"[ąàá]", "a", s)
s = s.replace("ł", "l").replace("ż", "z").replace("ź", "z").replace("ć", "c")
s = s.replace("ę", "e").replace("ó", "o").replace("ś", "s").replace("ń", "n")
s = re.sub(r"[^a-z0-9]+", "", s)
return s
def build_column_mapping(header_cells: list[str]) -> dict[str, str]:
"""Zwraca mapę pole_kanoniczne -> faktyczna_nazwa_kolumny dla danego pliku.
Kolumny nierozpoznane są zachowywane pod swoją (znormalizowaną) nazwą, więc
nic nie ginie — po prostu nie mają aliasu kanonicznego.
"""
reverse: dict[str, str] = {}
for canonical, aliases in CANONICAL_ALIASES.items():
for alias in aliases:
reverse[alias] = canonical
mapping: dict[str, str] = {}
for actual in header_cells:
norm = _normalize(actual)
canonical = reverse.get(norm, norm or "col")
# pierwsze trafienie wygrywa (stabilność przy duplikatach)
mapping.setdefault(canonical, actual)
return mapping
+43
View File
@@ -0,0 +1,43 @@
"""Wczytanie pojedynczego arkusza do znormalizowanej ramki danych.
Łączy wykrywanie nagłówka (header_detect) z mapowaniem układu kolumn (layout).
Zwraca ramkę o KANONICZNYCH nazwach kolumn — gotową do indeksowania, cache'owania
(parquet) i ewentualnego załadowania do SQL.
To jest jedyne miejsce, które "rozumie" format Excela. Reszta systemu jej nie
widzi.
"""
from __future__ import annotations
from dataclasses import dataclass
import pandas as pd
from app.excel.header_detect import detect_header_row
from app.excel.layout import build_column_mapping
@dataclass
class LoadedSheet:
frame: pd.DataFrame # dane z kanonicznymi kolumnami
header_row: int # wykryty indeks nagłówka
column_mapping: dict[str, str] # pole_kanoniczne -> oryginalna_nazwa
def load_sheet(path: str, sheet: str | int = 0, header_scan_rows: int = 15) -> LoadedSheet:
raw = pd.read_excel(path, sheet_name=sheet, header=None, dtype=object)
header_row = detect_header_row(raw, max_scan=header_scan_rows)
header_cells = [str(c) for c in raw.iloc[header_row].tolist()]
mapping = build_column_mapping(header_cells)
data = raw.iloc[header_row + 1 :].copy()
data.columns = header_cells
data = data.dropna(how="all")
# przenazwij na kanoniczne pola: {oryginał -> kanoniczne}
inverse = {orig: canon for canon, orig in mapping.items()}
data = data.rename(columns=inverse)
data = data.reset_index(drop=True)
return LoadedSheet(frame=data, header_row=header_row, column_mapping=mapping)
+25
View File
@@ -0,0 +1,25 @@
"""Wstępne zbudowanie cache + odwróconego indeksu dla wszystkich plików Excela.
Uruchom raz po wgraniu/aktualizacji plików (albo zostaw warmup przy starcie usługi):
python -m app.ingest.build_index
"""
from __future__ import annotations
from app.config import settings
from app.providers.excel_provider import ExcelDataProvider
def main() -> None:
provider = ExcelDataProvider(settings)
files = provider._excel_files()
print(f"Indeksuję {len(files)} plików z {settings.excel_dir} ...")
for i, path in enumerate(files, 1):
provider._ensure_indexed(path)
if i % 25 == 0 or i == len(files):
print(f" {i}/{len(files)}")
print(f"Gotowe. Zaindeksowane pliki: {provider.index.count_files()}")
if __name__ == "__main__":
main()
+53
View File
@@ -0,0 +1,53 @@
"""ETL migracji: setki plików Excela -> jedna zoptymalizowana tabela SQL.
To jest "łatwa droga na przyszłość". Skrypt używa DOKŁADNIE tego samego loadera
co warstwa Excela (wykrywanie nagłówka + mapowanie kolumn kanonicznych), więc
dane trafiają do SQL już znormalizowane i spójne. Po załadowaniu wystarczy
ustawić DATA_PROVIDER=sql.
python -m app.ingest.to_sql
Kroki:
1. wczytaj każdy plik loaderem -> ramka o kanonicznych kolumnach,
2. dołóż kolumnę źródła (_source_file) dla audytu,
3. dopisz do tabeli 'records',
4. załóż indeksy na kluczach kanonicznych (przyspieszenie zapytań).
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from sqlalchemy import create_engine, text
from app.config import settings
from app.excel.loader import load_sheet
def main() -> None:
engine = create_engine(settings.sql_url, future=True)
files = sorted(Path(settings.excel_dir).glob("**/*.xlsx"))
print(f"Migruję {len(files)} plików -> {settings.sql_url}")
first = True
for path in files:
if path.name.startswith("~$"):
continue
loaded = load_sheet(str(path), header_scan_rows=settings.header_scan_rows)
frame = loaded.frame.copy()
frame["_source_file"] = path.name
frame.to_sql("records", engine, if_exists="replace" if first else "append", index=False)
first = False
with engine.connect() as conn:
for key in settings.indexed_keys:
try:
conn.execute(text(f"CREATE INDEX IF NOT EXISTS ix_records_{key} ON records({key})"))
except Exception as e: # kolumna może nie istnieć w tym zbiorze
print(f" (pomijam indeks {key}: {e})")
conn.commit()
print("Migracja zakończona. Ustaw DATA_PROVIDER=sql aby przełączyć warstwę.")
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
"""Warstwa BAZODANOWA — usługa HTTP.
Jedyne zadanie: przyjąć znormalizowane zapytanie z warstwy logicznej, wyszukać
dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
ani prezentacji.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.factory import build_provider
provider = build_provider(settings)
@asynccontextmanager
async def lifespan(app: FastAPI):
provider.warmup() # zbuduj/odśwież indeks i cache przy starcie
yield
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
@app.post("/search", response_model=SearchResult)
def search(query: SearchQuery) -> SearchResult:
return provider.search(query)
@app.get("/health", response_model=HealthInfo)
def health() -> HealthInfo:
return provider.health()
+41
View File
@@ -0,0 +1,41 @@
"""Kontrakt danych warstwy bazodanowej.
Te modele są JEDYNYM publicznym interfejsem tej warstwy. Warstwa logiczna zna
wyłącznie te kształty (poprzez HTTP/JSON) — nie wie nic o Excelu, cache ani SQL.
Dzięki temu można podmienić implementację (Excel -> SQL) bez zmiany pozostałych
warstw.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class SearchQuery(BaseModel):
"""Znormalizowane zapytanie wyszukiwania przychodzące z warstwy logicznej."""
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
value: str = Field(..., description="Szukana wartość.")
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
limit: int = Field(50, ge=1, le=1000)
fields: list[str] | None = Field(
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
)
class SearchResult(BaseModel):
"""Wynik wyszukiwania zwracany w górę do warstwy logicznej."""
rows: list[dict[str, Any]]
total: int
elapsed_ms: float
cache: str = Field("miss", description="hit/miss/partial — skąd pochodzą dane.")
provider: str = Field(..., description="Nazwa aktywnej implementacji, np. 'excel' lub 'sql'.")
class HealthInfo(BaseModel):
status: str = "ok"
provider: str
indexed_files: int = 0
details: dict[str, Any] = Field(default_factory=dict)
+27
View File
@@ -0,0 +1,27 @@
"""Abstrakcyjny interfejs dostawcy danych (wzorzec Repository/Strategy).
To jest klucz do "łatwej migracji do SQL". Warstwa bazodanowa udostępnia na
zewnątrz tylko ten kontrakt. Dziś realizuje go ExcelDataProvider, jutro
SqlDataProvider — bez żadnej zmiany w warstwie logicznej i prezentacji.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from app.models import HealthInfo, SearchQuery, SearchResult
class DataProvider(ABC):
name: str = "base"
@abstractmethod
def search(self, query: SearchQuery) -> SearchResult:
"""Wyszuka dane i zwróci je w górę. JEDYNE zadanie tej warstwy."""
@abstractmethod
def health(self) -> HealthInfo:
...
def warmup(self) -> None:
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
return None
@@ -0,0 +1,143 @@
"""ExcelDataProvider — wyszukiwanie w setkach plików .xlsx z 4-poziomowym cache.
Ścieżka zapytania (od najszybszej):
1) QueryCache (in-memory) -> gotowy wynik
2) InvertedIndex (SQLite) -> które pliki w ogóle otwierać (zamiast skanu setek)
3) FrameCache (Parquet) -> wczytanie pliku bez parsowania .xlsx
4) SchemaCache (SQLite) -> bez ponownego wykrywania nagłówka/układu kolumn
...dopiero gdy wszystko spudłuje, czytamy .xlsx i wypełniamy cache.
Cała ta złożoność jest UKRYTA za interfejsem DataProvider.
"""
from __future__ import annotations
import time
from pathlib import Path
import pandas as pd
from app.cache.fingerprint import fingerprint
from app.cache.frame_cache import FrameCache
from app.cache.index import InvertedIndex
from app.cache.query_cache import QueryCache
from app.cache.schema_cache import SchemaCache
from app.config import Settings
from app.excel.header_detect import detect_header_row
from app.excel.layout import build_column_mapping
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.base import DataProvider
class ExcelDataProvider(DataProvider):
name = "excel"
def __init__(self, settings: Settings) -> None:
self.s = settings
self.schema = SchemaCache(settings.cache_dir)
self.frames = FrameCache(settings.cache_dir)
self.index = InvertedIndex(settings.cache_dir)
self.queries = QueryCache(settings.query_cache_size, settings.query_cache_ttl)
# ---- ładowanie pojedynczego arkusza z pełnym cache ----
def _load_frame(self, path: str, sheet: str | int = 0) -> pd.DataFrame:
fp = fingerprint(path)
sheet_key = str(sheet)
cached = self.frames.get(fp, sheet_key) # poziom 2: Parquet
if cached is not None:
return cached
raw = pd.read_excel(path, sheet_name=sheet, header=None, dtype=object)
meta = self.schema.get(fp, sheet_key) # poziom 1: schemat
if meta is None:
header_row = detect_header_row(raw, self.s.header_scan_rows)
header_cells = [str(c) for c in raw.iloc[header_row].tolist()]
mapping = build_column_mapping(header_cells)
self.schema.put(fp, sheet_key, header_row, mapping)
else:
header_row, mapping = meta
header_cells = [str(c) for c in raw.iloc[header_row].tolist()]
data = raw.iloc[header_row + 1 :].copy()
data.columns = header_cells
data = data.dropna(how="all")
inverse = {orig: canon for canon, orig in mapping.items()}
data = data.rename(columns=inverse).reset_index(drop=True)
self.frames.put(fp, sheet_key, data) # zapisz Parquet na przyszłość
return data
# ---- budowa odwróconego indeksu (warmup / po zmianie pliku) ----
def _ensure_indexed(self, path: str) -> None:
fp = fingerprint(path)
if self.index.file_fingerprint(path) == fp:
return # aktualny
frame = self._load_frame(path)
rows: list[tuple[str, str, str]] = []
for key in self.s.indexed_keys:
if key in frame.columns:
for v in frame[key].dropna().astype(str).unique():
rows.append((key, v, "0"))
self.index.reindex_file(path, fp, rows)
def warmup(self) -> None:
for path in self._excel_files():
self._ensure_indexed(path)
def _excel_files(self) -> list[str]:
base = Path(self.s.excel_dir)
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
# ---- publiczne API ----
def search(self, query: SearchQuery) -> SearchResult:
t0 = time.perf_counter()
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
if hit is not None:
hit = hit.model_copy(update={"cache": "hit", "elapsed_ms": _ms(t0)})
return hit
candidates = self.index.lookup(query.key, query.value, query.exact)
if not candidates:
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
candidates = [(p, "0") for p in self._excel_files()]
rows: list[dict] = []
for path, _sheet in candidates:
frame = self._load_frame(path)
if query.key not in frame.columns:
continue
col = frame[query.key].astype(str)
if query.exact:
mask = col.str.lower() == query.value.lower()
else:
mask = col.str.lower().str.contains(query.value.lower(), na=False)
matched = frame[mask]
if query.fields:
keep = [c for c in query.fields if c in matched.columns]
matched = matched[keep]
rows.extend(matched.to_dict(orient="records"))
if len(rows) >= query.limit:
break
result = SearchResult(
rows=rows[: query.limit],
total=len(rows),
elapsed_ms=_ms(t0),
cache="miss",
provider=self.name,
)
self.queries.put(cache_key, result)
return result
def health(self) -> HealthInfo:
return HealthInfo(
provider=self.name,
indexed_files=self.index.count_files(),
details={"excel_dir": str(self.s.excel_dir), "files_on_disk": len(self._excel_files())},
)
def _ms(t0: float) -> float:
return round((time.perf_counter() - t0) * 1000, 2)
+19
View File
@@ -0,0 +1,19 @@
"""Fabryka dostawcy danych — jedyne miejsce, które wie o konkretnych implementacjach.
Przełączenie Excel <-> SQL: ustaw DATA_PROVIDER w środowisku. Nic poza tym.
"""
from __future__ import annotations
from app.config import Settings
from app.providers.base import DataProvider
def build_provider(settings: Settings) -> DataProvider:
if settings.provider == "sql":
from app.providers.sql_provider import SqlDataProvider
return SqlDataProvider(settings)
from app.providers.excel_provider import ExcelDataProvider
return ExcelDataProvider(settings)
@@ -0,0 +1,47 @@
"""SqlDataProvider — implementacja docelowa (po migracji z Excela).
Szkielet. Realizuje TEN SAM interfejs DataProvider, więc przełączenie to tylko
zmiana zmiennej środowiskowej DATA_PROVIDER=sql (patrz factory.py). Warstwa
logiczna i prezentacji nie zmieniają ani jednej linii.
Dane ładuje do bazy skrypt ingest/to_sql.py (ten sam loader Excela -> tabele SQL).
"""
from __future__ import annotations
import time
from sqlalchemy import create_engine, text
from app.config import Settings
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.base import DataProvider
class SqlDataProvider(DataProvider):
name = "sql"
def __init__(self, settings: Settings) -> None:
self.s = settings
self.engine = create_engine(settings.sql_url, future=True)
def search(self, query: SearchQuery) -> SearchResult:
t0 = time.perf_counter()
op = "=" if query.exact else "LIKE"
val = query.value if query.exact else f"%{query.value}%"
cols = ", ".join(query.fields) if query.fields else "*"
# UWAGA: nazwy kolumn/tabel walidować względem białej listy schematu.
sql = text(f"SELECT {cols} FROM records WHERE {query.key} {op} :v LIMIT :lim")
with self.engine.connect() as conn:
rows = [dict(r._mapping) for r in conn.execute(sql, {"v": val, "lim": query.limit})]
return SearchResult(
rows=rows,
total=len(rows),
elapsed_ms=round((time.perf_counter() - t0) * 1000, 2),
cache="miss",
provider=self.name,
)
def health(self) -> HealthInfo:
with self.engine.connect() as conn:
n = conn.execute(text("SELECT COUNT(*) FROM records")).scalar() or 0
return HealthInfo(provider=self.name, indexed_files=0, details={"records": int(n)})
View File
+9
View File
@@ -0,0 +1,9 @@
# Dolne ograniczenia (>=) — działa zarówno na Pythonie 3.12 (obraz Docker),
# jak i na najnowszym 3.14 lokalnie. Przypnij dokładne wersje, gdy ustabilizujesz środowisko.
fastapi>=0.115
uvicorn[standard]>=0.34
pandas>=2.2
openpyxl>=3.1
pyarrow>=18.0
SQLAlchemy>=2.0
pydantic>=2.10
+43
View File
@@ -0,0 +1,43 @@
"""Generuje kilka przykładowych plików .xlsx do dema.
Celowo różnicuje: pozycję nagłówka (puste wiersze/tytuł nad nagłówkiem) oraz
kolejność i nazwy kolumn ("Imię"/"Name", "Symbol"/"Znak") — żeby pokazać działanie
wykrywania nagłówka i mapowania układu kolumn.
python scripts/make_sample_data.py
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
OUT = Path(__file__).resolve().parent.parent / "data_files"
OUT.mkdir(parents=True, exist_ok=True)
SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo"]
def file_a() -> None:
# nagłówek w 1. wierszu, nazwy PL
df = pd.DataFrame(
{"id": [1, 2, 3], "Imię": SIGNS[:3], "Symbol": ["", "", ""], "Wartość": [10, 20, 30]}
)
df.to_excel(OUT / "zodiac_pl.xlsx", index=False)
def file_b() -> None:
# tytuł + pusty wiersz nad nagłówkiem, nazwy EN, inna kolejność kolumn
with pd.ExcelWriter(OUT / "zodiac_en.xlsx") as xl:
meta = pd.DataFrame([["Tabela astrologiczna — wersja 2"], [None]])
meta.to_excel(xl, index=False, header=False, startrow=0)
df = pd.DataFrame(
{"Sign": ["", "", ""], "Name": SIGNS[3:], "No": [4, 5, 6], "Value": [40, 50, 60]}
)
df.to_excel(xl, index=False, startrow=2)
if __name__ == "__main__":
file_a()
file_b()
print(f"Zapisano przykładowe pliki w {OUT}")
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8001
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
+24
View File
@@ -0,0 +1,24 @@
# Warstwa logiczna (`logic`)
Niezależna usługa pośrednicząca. **W górę** udostępnia API dla prezentacji,
**w dół** woła warstwę bazodanową. Tu żyją reguły biznesowe — nie w prezentacji
i nie w bazie.
## API
- `POST /api/query``QueryRequest``QueryResponse`
- `GET /health` (sprawdza też warstwę bazodanową)
## Zależności w dół
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
Nie wie, czy pod spodem jest Excel czy SQL.
## Uruchomienie
```bash
pip install -r requirements.txt
export DATA_URL=http://localhost:8002
uvicorn app.main:app --port 8001
```
## Gdzie rozbudowywać domenę
`service.py``QueryService.handle()`: walidacja wejścia, tłumaczenie zapytania,
obliczenia i wzbogacanie wyników.
View File
+30
View File
@@ -0,0 +1,30 @@
"""Klient HTTP do warstwy bazodanowej.
Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
(Excel→SQL), tutaj nie zmienia się NIC — kontrakt /search jest stały.
"""
from __future__ import annotations
from typing import Any
import httpx
from app.config import settings
class DataClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.data_url).rstrip("/")
def search(self, key: str, value: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit}
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.post(f"{self.base_url}/search", json=payload)
r.raise_for_status()
return r.json()
def health(self) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health")
r.raise_for_status()
return r.json()
+18
View File
@@ -0,0 +1,18 @@
"""Konfiguracja warstwy logicznej.
Zna TYLKO adres warstwy bazodanowej (w dół). Nie wie nic o jej wnętrzu
(Excel/SQL/cache).
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass
class Settings:
data_url: str = field(default_factory=lambda: os.getenv("DATA_URL", "http://localhost:8002"))
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
settings = Settings()
+35
View File
@@ -0,0 +1,35 @@
"""Warstwa LOGICZNA — usługa HTTP.
W górę: udostępnia API dla warstwy prezentacji.
W dół: woła warstwę bazodanową (DataClient).
Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
"""
from __future__ import annotations
import httpx
from fastapi import FastAPI, HTTPException
from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse
from app.service import QueryService
app = FastAPI(title="astrololo · warstwa logiczna")
service = QueryService()
@app.post("/api/query", response_model=QueryResponse)
def query(req: QueryRequest) -> QueryResponse:
try:
return service.handle(req)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
@app.get("/health")
def health() -> dict:
info = {"status": "ok", "layer": "logic"}
try:
info["data_layer"] = DataClient().health()
except httpx.HTTPError as e:
info["data_layer"] = {"status": "down", "error": str(e)}
return info
+25
View File
@@ -0,0 +1,25 @@
"""Kontrakt warstwy logicznej (widziany przez warstwę prezentacji)."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class QueryRequest(BaseModel):
"""To, co przychodzi z formularza (przez warstwę prezentacji)."""
query: str = Field(..., min_length=1, description="Szukana fraza.")
field: str = Field("name", description="Po którym polu szukać.")
exact: bool = False
limit: int = Field(25, ge=1, le=200)
class QueryResponse(BaseModel):
"""To, co wraca w górę do prezentacji."""
status: str = "ok"
query: str
count: int
results: list[dict[str, Any]]
meta: dict[str, Any] = Field(default_factory=dict)
+43
View File
@@ -0,0 +1,43 @@
"""Logika biznesowa — serce warstwy logicznej.
Tu (a nie w prezentacji ani w bazie) żyją reguły: walidacja/normalizacja danych
z formularza, tłumaczenie zapytania użytkownika na znormalizowane zapytanie do
bazy, oraz opracowanie/wzbogacenie wyników w drodze w górę.
To jest miejsce do rozbudowy o właściwą domenę (obliczenia, reguły, agregacje).
"""
from __future__ import annotations
from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse
class QueryService:
def __init__(self, data_client: DataClient | None = None) -> None:
self.data = data_client or DataClient()
def handle(self, req: QueryRequest) -> QueryResponse:
# 1) normalizacja wejścia z formularza (reguła biznesowa)
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)
# 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować)
results = raw.get("rows", [])
results = sorted(results, key=lambda r: str(r.get(key, "")))
return QueryResponse(
status="ok",
query=value,
count=len(results),
results=results,
meta={
"field": key,
"exact": req.exact,
"data_cache": raw.get("cache"),
"data_provider": raw.get("provider"),
"data_elapsed_ms": raw.get("elapsed_ms"),
},
)
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.34
httpx>=0.28
pydantic>=2.10
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+21
View File
@@ -0,0 +1,21 @@
# Warstwa prezentacji (`presentation`)
Niezależna usługa serwująca stronę WWW (formularz + tabela wyników). **W dół**
przekazuje dane z formularza do warstwy logicznej i renderuje opracowane wyniki.
Brak logiki biznesowej i dostępu do danych.
## Trasy
- `GET /` — strona z formularzem
- `POST /` — wysłanie formularza → warstwa logiczna → render wyników
- `GET /health`
## Zależności w dół
Zna wyłącznie `LOGIC_URL` (adres warstwy logicznej).
## Uruchomienie
```bash
pip install -r requirements.txt
export LOGIC_URL=http://localhost:8001
uvicorn app.main:app --port 8000
# otwórz http://localhost:8000
```
@@ -0,0 +1,24 @@
"""Klient HTTP do warstwy logicznej.
Jedyny punkt styku prezentacji w dół. Przekazuje dane z formularza i odbiera
opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy.
"""
from __future__ import annotations
from typing import Any
import httpx
from app.config import settings
class LogicClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/")
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.post(f"{self.base_url}/api/query", json=payload)
r.raise_for_status()
return r.json()
+17
View File
@@ -0,0 +1,17 @@
"""Konfiguracja warstwy prezentacji.
Zna TYLKO adres warstwy logicznej (w dół). Nie wie nic o bazie/Excelu/SQL.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass
class Settings:
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
settings = Settings()
+46
View File
@@ -0,0 +1,46 @@
"""Warstwa PREZENTACJI — usługa HTTP serwująca stronę WWW.
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
"""
from __future__ import annotations
import httpx
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.clients.logic_client import LogicClient
app = FastAPI(title="astrololo · warstwa prezentacji")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
logic = LogicClient()
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
return templates.TemplateResponse(request, "index.html", {"result": None, "form": {}})
@app.post("/", response_class=HTMLResponse)
def 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)
except httpx.HTTPError as e:
ctx["error"] = f"Warstwa logiczna niedostępna: {e}"
return templates.TemplateResponse(request, "index.html", ctx)
@app.get("/health")
def health() -> dict:
return {"status": "ok", "layer": "presentation"}
@@ -0,0 +1,35 @@
:root {
--bg: #0f1020;
--panel: #1a1c33;
--ink: #e8e8f0;
--muted: #9aa0c0;
--accent: #8b7bf0;
--line: #2a2d4a;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; justify-content: center; padding: 3rem 1rem;
}
main { width: 100%; max-width: 880px; }
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
.sub { color: var(--muted); margin: .25rem 0 2rem; }
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
.row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; }
input, select, button {
font: inherit; padding: .6rem .75rem; border-radius: 8px;
border: 1px solid var(--line); background: #12132a; color: var(--ink);
}
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
button:hover { filter: brightness(1.1); }
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
.opts input[type=number] { width: 5rem; }
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.empty { color: var(--muted); }
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
tr:last-child td { border-bottom: none; }
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main>
<h1>astrololo</h1>
<p class="sub">Warstwa prezentacji → logiczna → bazodanowa</p>
<form method="post" action="/">
<div class="row">
<input type="text" name="query" placeholder="Szukana fraza…"
value="{{ form.query or '' }}" autofocus required>
<select name="field">
{% for f in ["name", "id", "symbol", "category", "value"] %}
<option value="{{ f }}" {{ 'selected' if form.field == f else '' }}>{{ f }}</option>
{% endfor %}
</select>
<button type="submit">Szukaj</button>
</div>
<div class="opts">
<label><input type="checkbox" name="exact" value="true"
{{ 'checked' if form.exact else '' }}> dokładne</label>
<label>limit
<input type="number" name="limit" min="1" max="200" value="{{ form.limit or 25 }}">
</label>
</div>
</form>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
{% if result %}
<div class="meta">
Znaleziono <strong>{{ result.count }}</strong> ·
provider: {{ result.meta.data_provider }} ·
cache: {{ result.meta.data_cache }} ·
{{ result.meta.data_elapsed_ms }} ms
</div>
{% if result.results %}
<table>
<thead>
<tr>{% for col in result.results[0].keys() %}<th>{{ col }}</th>{% endfor %}</tr>
</thead>
<tbody>
{% for row in result.results %}
<tr>{% for v in row.values() %}<td>{{ v }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Brak wyników.</p>
{% endif %}
{% endif %}
</main>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.115
uvicorn[standard]>=0.34
httpx>=0.28
jinja2>=3.1
python-multipart>=0.0.20