frontend admin refakctorálás
This commit is contained in:
381
plans/logic_spec_i18n_jsonb_migration_phase1.md
Normal file
381
plans/logic_spec_i18n_jsonb_migration_phase1.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# 🏗️ Logic Spec: P0 EPIC - i18n JSONB Database Migration (Phase 1)
|
||||
|
||||
**Gitea Issue:** [#399](https://gitea.profibot.hu/kincses/service-finder/issues/399)
|
||||
**Státusz:** Tervezés alatt (Architect Review)
|
||||
**Dátum:** 2026-07-07
|
||||
**Architect:** Rendszer-Architect
|
||||
|
||||
---
|
||||
|
||||
## 1. Cél és Masterbook 2.0 Illeszkedés
|
||||
|
||||
### 1.1 Cél
|
||||
A 24 EU nyelv támogatásához egységes JSONB-alapú lokalizációs architektúra bevezetése. A jelenlegi gyakorlat, ahol minden nyelvhez külön VARCHAR oszlop tartozik (`name_hu`, `name_en`), nem skálázható. Helyette egyetlen JSONB oszlopot (`name_i18n`) használunk, ahol a kulcsok a nyelvi kódok (pl. `{"hu": "Szedán", "en": "Sedan", "de": "Limousine"}`).
|
||||
|
||||
### 1.2 Masterbook 2.0 Illeszkedés
|
||||
- **2A Elv:** A kód a mérvadó → a Wiki frissítése a migráció után kötelező.
|
||||
- **DDD szeparáció:** Minden érintett tábla külön schema-ban van (`marketplace`, `vehicle`, `system`).
|
||||
- **Soft-delete:** Nem releváns (dictionary táblák, nincs soft-delete).
|
||||
|
||||
---
|
||||
|
||||
## 2. Hatáselemzés (Impact Analysis)
|
||||
|
||||
### 2.1 Érintett Táblák (Database)
|
||||
|
||||
| Schema | Tábla | Jelenlegi Oszlopok | Cél Oszlopok | Megjegyzés |
|
||||
|--------|-------|-------------------|--------------|------------|
|
||||
| `marketplace` | `expertise_tags` | `name_hu VARCHAR`, `name_en VARCHAR`, `name_translations JSONB`, `description TEXT` | `name_i18n JSONB`, `description_i18n JSONB` | `name_translations` megmarad opcionális kiegészítőként |
|
||||
| `vehicle` | `dict_body_types` | `name_hu VARCHAR` | `name_i18n JSONB` | Egyszerű csere |
|
||||
| `system` | `service_catalog` | `name VARCHAR`, `description VARCHAR` | `name_i18n JSONB`, `description_i18n JSONB` | Teljesen új oszlopok |
|
||||
|
||||
### 2.2 Érintett Modell Fájlok (SQLAlchemy)
|
||||
|
||||
| Fájl | Osztály | Művelet |
|
||||
|------|---------|---------|
|
||||
| `backend/app/models/marketplace/service.py` | `ExpertiseTag` | Add `name_i18n`, `description_i18n`; Remove `name_hu`, `name_en`; Keep `name_translations` |
|
||||
| `backend/app/models/vehicle/vehicle_definitions.py` | `BodyTypeDictionary` | Replace `name_hu` with `name_i18n` |
|
||||
| `backend/app/models/core_logic.py` | `ServiceCatalog` | Replace `name` with `name_i18n`, `description` with `description_i18n` |
|
||||
|
||||
### 2.3 Érintett Schema Fájlok (Pydantic)
|
||||
|
||||
| Fájl | Osztály | Művelet |
|
||||
|------|---------|---------|
|
||||
| `backend/app/schemas/provider.py` | `CategoryTreeNode`, `CategoryAutocompleteItem`, `ExpertiseCategoryOut`, `CategoryInfo` | Replace `name_hu`/`name_en` with `name_i18n` |
|
||||
| `backend/app/schemas/service_catalog.py` | `ServiceCatalogCreate`, `ServiceCatalogUpdate`, `ServiceCatalogResponse` | Replace `name`/`description` with `name_i18n`/`description_i18n` |
|
||||
|
||||
### 2.4 Érintett Végpontok (API)
|
||||
|
||||
| Fájl | Végpont | Művelet |
|
||||
|------|---------|---------|
|
||||
| `backend/app/api/v1/endpoints/providers.py` | `GET /categories/tree` | Frissíteni `name_hu`/`name_en` → `name_i18n` |
|
||||
| `backend/app/api/v1/endpoints/providers.py` | `GET /categories/autocomplete` | Frissíteni a keresést `name_i18n`-re (JSONB contains) |
|
||||
| `backend/app/api/v1/endpoints/providers.py` | `GET /categories` | Frissíteni a response-öt |
|
||||
| `backend/app/api/v1/endpoints/catalog.py` | `GET /catalog/body-types` | Frissíteni `name_hu` → `name_i18n` |
|
||||
| `backend/app/api/v1/endpoints/admin_services.py` | `GET/POST/PATCH /admin/services` | Frissíteni ServiceCatalog schema-t |
|
||||
|
||||
### 2.5 Érintett Seed Szkriptek
|
||||
|
||||
| Fájl | Művelet |
|
||||
|------|---------|
|
||||
| `backend/scripts/seed_body_types.py` | Frissíteni `name_hu` → `name_i18n` |
|
||||
| `backend/app/scripts/seed_services.py` | Frissíteni `name`/`description` → `name_i18n`/`description_i18n` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Adatmodell Terv
|
||||
|
||||
### 3.1 JSONB Oszlop Formátum
|
||||
|
||||
```json
|
||||
{
|
||||
"hu": "Magyar név",
|
||||
"en": "English name",
|
||||
"de": "Deutscher Name"
|
||||
}
|
||||
```
|
||||
|
||||
**Kötelező mező:** Minden `name_i18n` oszlopban a `"hu"` kulcs kötelező (`server_default='{"hu": ""}'::jsonb`).
|
||||
|
||||
### 3.2 GIN Index
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_expertise_tags_name_i18n ON marketplace.expertise_tags USING GIN (name_i18n);
|
||||
CREATE INDEX idx_dict_body_types_name_i18n ON vehicle.dict_body_types USING GIN (name_i18n);
|
||||
CREATE INDEX idx_service_catalog_name_i18n ON system.service_catalog USING GIN (name_i18n);
|
||||
```
|
||||
|
||||
### 3.3 Részletes Oszlop Tervek
|
||||
|
||||
#### `marketplace.expertise_tags`
|
||||
|
||||
```python
|
||||
# ÚJ oszlopok (ADD)
|
||||
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
# MEGTARTVA (a meglévő name_translations továbbra is használható bővített fordításokhoz)
|
||||
name_translations: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
|
||||
# TÖRLENDŐ oszlopok (DROP)
|
||||
# name_hu: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
# name_en: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
# description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
```
|
||||
|
||||
> **Döntés:** A `name_translations` oszlop MEGMARAD, mivel a `name_i18n` csak az alap 24 EU nyelvet tartalmazza, a `name_translations` pedig extra (nem EU) nyelvekhez vagy bővített lokalizációhoz használható.
|
||||
|
||||
#### `vehicle.dict_body_types`
|
||||
|
||||
```python
|
||||
# ÚJ oszlop (ADD)
|
||||
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||
|
||||
# TÖRLENDŐ oszlop (DROP)
|
||||
# name_hu: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
```
|
||||
|
||||
#### `system.service_catalog`
|
||||
|
||||
```python
|
||||
# ÚJ oszlopok (ADD)
|
||||
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
# TÖRLENDŐ oszlopok (DROP)
|
||||
# name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
# description: Mapped[Optional[str]] = mapped_column(String)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Admin Kontroll (Global/Country/Region/User)
|
||||
|
||||
Nem releváns — ezek dictionary táblák, a tartalmat seed szkriptek vagy admin CRUD kezeli. A jövőben az admin felületen JSON-szerkesztő mezővel lehet majd szerkeszteni a `name_i18n` tartalmát.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migrációs Terv (Execution Sequence)
|
||||
|
||||
### 5.1 Fázisok
|
||||
|
||||
```
|
||||
FÁZIS 1: Add új oszlopok (sync_engine)
|
||||
FÁZIS 2: Adatmigráció (migrate_i18n_jsonb.py script)
|
||||
FÁZIS 3: Modell és kód frissítése
|
||||
FÁZIS 4: Régi oszlopok eltávolítása (sync_engine)
|
||||
FÁZIS 5: Seed szkriptek frissítése
|
||||
FÁZIS 6: Tesztelés
|
||||
```
|
||||
|
||||
### 5.2 Részletes Lépések
|
||||
|
||||
#### FÁZIS 1: Séma Bővítés
|
||||
1. Módosítani a 3 modellt: hozzáadni az új `name_i18n` és `description_i18n` JSONB oszlopokat (a régiek megtartása mellett).
|
||||
2. Futtatni: `docker exec sf_api python3 -m app.scripts.sync_engine`
|
||||
3. Ellenőrizni: `SELECT column_name FROM information_schema.columns WHERE table_name IN ('expertise_tags', 'dict_body_types', 'service_catalog') AND column_name LIKE '%i18n%'`
|
||||
|
||||
#### FÁZIS 2: Adatmigráció
|
||||
1. Létrehozni: `backend/app/scripts/migrate_i18n_jsonb.py`
|
||||
2. A script logikája:
|
||||
- **ExpertiseTag:** `name_i18n = {"hu": name_hu, "en": name_en} ++ name_translations`
|
||||
- **BodyTypeDictionary:** `name_i18n = {"hu": name_hu}`
|
||||
- **ServiceCatalog:** `name_i18n = {"hu": name}`, `description_i18n = {"hu": description}`
|
||||
3. Futtatni: `docker exec sf_api python3 /app/backend/app/scripts/migrate_i18n_jsonb.py`
|
||||
4. Ellenőrizni: `SELECT id, name_i18n FROM marketplace.expertise_tags LIMIT 5`
|
||||
|
||||
#### FÁZIS 3: Kód Frissítés
|
||||
1. Módosítani a 3 SQLAlchemy modellt:
|
||||
- Eltávolítani a régi `name_hu`, `name_en` oszlopokat a modellből
|
||||
- Hozzáadni az új `name_i18n` mezőt
|
||||
2. Módosítani a Pydantic schemákat
|
||||
3. Módosítani az API végpontokat
|
||||
4. Módosítani a seed szkripteket
|
||||
|
||||
#### FÁZIS 4: Régi Oszlopok Eltávolítása
|
||||
1. A modell frissítése után a `sync_engine` automatikusan eltávolítja a régi oszlopokat (ha a `sync_engine` támogatja a DROP COLUMN-t)
|
||||
2. VAGY manuális SQL: `ALTER TABLE marketplace.expertise_tags DROP COLUMN name_hu, DROP COLUMN name_en;`
|
||||
|
||||
> **FIGYELEM:** A DROP COLUMN művelet VISSZAFORDÍTHATATLAN! A script lefuttatása ELŐTT adatbázis mentés KÖTELEZŐ!
|
||||
|
||||
#### FÁZIS 5: Seed Szkriptek Frissítése
|
||||
1. `seed_body_types.py`: `name_hu="Szedán"` → `name_i18n={"hu": "Szedán"}`
|
||||
2. `seed_services.py`: `name="Adat Export"` → `name_i18n={"hu": "Adat Export"}`
|
||||
|
||||
#### FÁZIS 6: Tesztelés
|
||||
1. Ellenőrizni a GET /categories/tree végpontot
|
||||
2. Ellenőrizni a GET /catalog/body-types végpontot
|
||||
3. Ellenőrizni a GET/POST /admin/services végpontokat
|
||||
4. Ellenőrizni az autocomplete keresést
|
||||
|
||||
---
|
||||
|
||||
## 6. Adatmigrációs Script Terve
|
||||
|
||||
A `backend/app/scripts/migrate_i18n_jsonb.py` script az alábbi logikát követi:
|
||||
|
||||
```python
|
||||
"""
|
||||
i18n JSONB Migration Script (Phase 1)
|
||||
Migrates old name_hu/name_en columns to name_i18n JSONB format.
|
||||
Run INSIDE the sf_api container:
|
||||
docker exec sf_api python3 /app/backend/app/scripts/migrate_i18n_jsonb.py
|
||||
|
||||
IMPORTANT: Run this AFTER the new JSONB columns have been added by sync_engine.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
TABLES = [
|
||||
{
|
||||
"schema": "marketplace",
|
||||
"table": "expertise_tags",
|
||||
"name_cols": ["name_hu", "name_en"],
|
||||
"name_translations_col": "name_translations",
|
||||
"desc_cols": ["description"],
|
||||
"target_name_col": "name_i18n",
|
||||
"target_desc_col": "description_i18n",
|
||||
},
|
||||
{
|
||||
"schema": "vehicle",
|
||||
"table": "dict_body_types",
|
||||
"name_cols": ["name_hu"],
|
||||
"name_translations_col": None,
|
||||
"desc_cols": [],
|
||||
"target_name_col": "name_i18n",
|
||||
"target_desc_col": None,
|
||||
},
|
||||
{
|
||||
"schema": "system",
|
||||
"table": "service_catalog",
|
||||
"name_cols": ["name"],
|
||||
"name_translations_col": None,
|
||||
"desc_cols": ["description"],
|
||||
"target_name_col": "name_i18n",
|
||||
"target_desc_col": "description_i18n",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def migrate_table(config: dict):
|
||||
schema = config["schema"]
|
||||
table = config["table"]
|
||||
target_name = config["target_name_col"]
|
||||
target_desc = config["target_desc_col"]
|
||||
name_cols = config["name_cols"]
|
||||
desc_cols = config["desc_cols"]
|
||||
trans_col = config["name_translations_col"]
|
||||
|
||||
print(f"\nMigrating: {schema}.{table}")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
select_cols = ["id"] + name_cols + desc_cols
|
||||
if trans_col:
|
||||
select_cols.append(trans_col)
|
||||
|
||||
stmt = text(
|
||||
f"SELECT {', '.join(select_cols)} FROM {schema}.{table} "
|
||||
f"WHERE {target_name} IS NULL OR {target_name} = '{{\"hu\": \"\"}}'::jsonb"
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.fetchall()
|
||||
|
||||
if not rows:
|
||||
print(f" No rows need migration in {schema}.{table}")
|
||||
return
|
||||
|
||||
updated = 0
|
||||
for row in rows:
|
||||
row_dict = row._mapping
|
||||
|
||||
# Build name_i18n
|
||||
name_i18n = {}
|
||||
if len(name_cols) >= 1 and row_dict.get(name_cols[0]):
|
||||
name_i18n["hu"] = row_dict[name_cols[0]]
|
||||
if len(name_cols) >= 2 and row_dict.get(name_cols[1]):
|
||||
name_i18n["en"] = row_dict[name_cols[1]]
|
||||
|
||||
# Merge with existing name_translations
|
||||
if trans_col and row_dict.get(trans_col):
|
||||
if isinstance(row_dict[trans_col], dict):
|
||||
name_i18n.update(row_dict[trans_col])
|
||||
|
||||
# Build description_i18n
|
||||
desc_i18n = None
|
||||
if desc_cols and row_dict.get(desc_cols[0]):
|
||||
desc_i18n = {"hu": row_dict[desc_cols[0]]}
|
||||
|
||||
# Update
|
||||
update_cols = [f"{target_name} = :name_val"]
|
||||
params = {"name_val": name_i18n, "row_id": row_dict["id"]}
|
||||
|
||||
if target_desc and desc_i18n:
|
||||
update_cols.append(f"{target_desc} = :desc_val")
|
||||
params["desc_val"] = desc_i18n
|
||||
|
||||
update_sql = f"UPDATE {schema}.{table} SET {', '.join(update_cols)} WHERE id = :row_id"
|
||||
await db.execute(text(update_sql), params)
|
||||
updated += 1
|
||||
|
||||
await db.commit()
|
||||
print(f" Migrated {updated} rows in {schema}.{table}")
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print(" i18n JSONB Migration Script (Phase 1)")
|
||||
print("=" * 70)
|
||||
|
||||
for table_config in TABLES:
|
||||
await migrate_table(table_config)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" Migration complete!")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Kockázatok és Megjegyzések
|
||||
|
||||
### 7.1 Kockázatok
|
||||
1. **Adatvesztés:** A DROP COLUMN visszafordíthatatlan. Minden fázis előtt adatbázis mentés kell!
|
||||
2. **API kompatibilitás:** A frontend `name_hu` és `name_en` mezőket használ. A migration során ezeket IDEIGLENESEN meg kell tartani backward compatibility miatt, majd a frontendet is át kell állítani.
|
||||
3. **Teljesítmény:** A JSONB keresés (`ILIKE` helyett `jsonb_extract_path_text`) eltérő lehet. Az autocomplete endpointot át kell írni.
|
||||
|
||||
### 7.2 Backward Compatibility Terv
|
||||
- **Phase 1 (jelenlegi):** Add `name_i18n`, migrate data, keep old columns temporarily
|
||||
- **Phase 2:** Update frontend to use `name_i18n`
|
||||
- **Phase 3:** Drop old columns
|
||||
|
||||
### 7.3 JSONB Keresési Példák
|
||||
|
||||
```sql
|
||||
-- Magyar név alapján keresés (ILIKE helyett)
|
||||
SELECT * FROM marketplace.expertise_tags
|
||||
WHERE name_i18n->>'hu' ILIKE '%szerelő%';
|
||||
|
||||
-- Angol név alapján keresés
|
||||
SELECT * FROM marketplace.expertise_tags
|
||||
WHERE name_i18n->>'en' ILIKE '%mechanic%';
|
||||
|
||||
-- Bármely nyelvben keresés (drága, használj GIN indexet)
|
||||
SELECT * FROM marketplace.expertise_tags
|
||||
WHERE name_i18n::text ILIKE '%mechanic%';
|
||||
```
|
||||
|
||||
### 7.4 GIN Index Teljesítmény
|
||||
A GIN index a `jsonb_path_ops` operátor osztállyal gyorsítja a `@>`, `?`, `?|`, `?&` operátorokat.
|
||||
|
||||
```sql
|
||||
-- GIN-hatékony keresés: pontosan "mechanic" bármely kulcson
|
||||
SELECT * FROM marketplace.expertise_tags
|
||||
WHERE name_i18n @> '{"en": "mechanic"}'::jsonb;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Összefoglaló és Jóváhagyási Kérelem
|
||||
|
||||
A terv 3 tábla, 5 fájl (3 model + 2 schema) módosítását, 1 új adatmigrációs script létrehozását, valamint 5 API végpont és 2 seed szkript frissítését tartalmazza.
|
||||
|
||||
**A jóváhagyás után:**
|
||||
1. Code módba váltás
|
||||
2. Modell fájlok módosítása (add new columns)
|
||||
3. `sync_engine` futtatás
|
||||
4. Adatmigrációs script futtatás
|
||||
5. Kód frissítés (schemák, végpontok, seed szkriptek)
|
||||
6. Tesztelés
|
||||
|
||||
**Architect:** Jóváhagyásra vár. Kérem a visszajelzést a tervvel kapcsolatban!
|
||||
Reference in New Issue
Block a user