# Template: Modul Oracle Nou în ROA2WEB **Audience**: developer care implementează un modul Oracle nou în 2h. **Bazat pe**: `backend/modules/service_auto/` — codul real, nu spec. **Branch de referință**: `feat/service-auto` --- ## 1. Prerequisites ### 1.1. `.env` — adaugă serverul Oracle `ORACLE_SERVERS` e un JSON array în `backend/.env`, `backend/.env.prod`, `backend/.env.test`: ```json ORACLE_SERVERS=[ {"id":"central","name":"...", "host":"...","port":1521,"user":"CONTAFIN_ORACLE","service_name":"ROA"}, {"id":"MY_SERVER_ID","name":"DESCRIERE","host":"IP_SERVER","port":1521,"user":"ROA_WEB","service_name":"ROA"} ] ``` Parola Oracle: `backend/secrets/MY_SERVER_ID.oracle_pass` (text plain, gitignored). `main.py:init_oracle_pool()` parcurge automat lista și apelează `oracle_pool.register_server()` pentru fiecare — nu e nevoie de cod nou. ### 1.2. Grants Oracle — Arhitectura de producție **Server**: Oracle 21c XE. Schema-level grants (23ai+) nu sunt disponibile. #### Faza A (prototype) — CONTAFIN_ORACLE direct Zero DBA work. CONTAFIN_ORACLE are rol DBA → acces la orice schemă. Swap la ROA_WEB în faza B. #### Faza B — ROA_WEB cu EXECUTE-only pe SP-uri (recomandat pentru producție) **De ce nu proxy auth**: proxy auth dă ROA_WEB identitatea completă a schemei → SELECT/INSERT direct pe orice tabelă → pierde beneficiul de securitate (SQL injection mitigation). Vezi `docs/service-auto/grants-audit.md` §3.1. ```sql -- O singură dată (DBA): CREATE USER ROA_WEB IDENTIFIED BY ""; GRANT CREATE SESSION TO ROA_WEB; -- La onboarding firmă nouă (după impdp din schema template): GRANT EXECUTE ON SCHEMA_FIRMA.SP_NUMESC TO ROA_WEB; GRANT SELECT ON SCHEMA_FIRMA.VIEW_LOOKUP_1 TO ROA_WEB; GRANT SELECT ON SCHEMA_FIRMA.VIEW_LOOKUP_2 TO ROA_WEB; -- La fiecare migrare care adaugă SP/view noi (loop V_NOM_FIRME, o singură dată): BEGIN FOR firm IN (SELECT DISTINCT schema FROM contafin_oracle.v_nom_firme WHERE schema IS NOT NULL) LOOP BEGIN EXECUTE IMMEDIATE 'GRANT EXECUTE ON ' || firm.schema || '.SP_NOU_DIN_MIGRARE TO ROA_WEB'; EXCEPTION WHEN OTHERS THEN NULL; END; END LOOP; END; / ``` Conectare Python: `user="ROA_WEB"` + `session_callback` pentru `CURRENT_SCHEMA`. Pattern complet în `docs/service-auto/grants-audit.md` §4. --- ## 2. File Layout ``` backend/modules/MODUL_NOU/ ├── __init__.py # Logger setup — OBLIGATORIU cu propagate=False ├── models/ │ └── __init__.py # SQLModel / dataclass-uri, dacă ai nevoie (poate fi gol) ├── schemas/ │ └── cerere.py # Pydantic: Request + Response (doar câmpuri, zero logică) ├── services/ │ └── cerere_service.py # Oracle: _handle_oracle_error() + clasa ServiceXxx ├── routers/ │ ├── __init__.py # create_MODUL_NOU_router() factory │ └── cerere.py # FastAPI router: /ping + endpoint-uri └── tests/ ├── test_error_mapping.py # Unit: mock Oracle errors → HTTP status (no live DB) ├── test_cerere_integration.py # Integration: live DB, marcat pytest.mark.integration └── test_cerere_persist.py # Integration: commit + reconnect + SELECT ``` **Înregistrare în `main.py`** (2 linii): ```python from backend.modules.MODUL_NOU.routers import create_MODUL_NOU_router # în lifespan / app setup: app.include_router(create_MODUL_NOU_router(), prefix="/api/MODUL-NOU", tags=["MODUL-NOU"]) ``` --- ## 3. Logging Setup (`__init__.py`) Copiază exact din `service_auto/__init__.py` — schimbă doar `'service_auto'` și `'service_auto.log'`: ```python import logging from pathlib import Path _LOG_DIR = Path(__file__).resolve().parents[2] / 'logs' _LOG_DIR.mkdir(parents=True, exist_ok=True) logger = logging.getLogger('MODUL_NOU') logger.propagate = False # Nu duplica în root logger if not logger.handlers: fh = logging.FileHandler(_LOG_DIR / 'MODUL_NOU.log') fh.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s: %(message)s')) logger.addHandler(fh) logger.setLevel(logging.INFO) ``` > `propagate=False` + `FileHandler` cu **path absolut** = log separat per modul, fără > interférence cu root logger-ul din `main.py`. Path relativ ar fi ambiguu la `cwd`. --- ## 4. SP Signature Convention ```sql CREATE OR REPLACE PROCEDURE SCHEMA_TA.SP_NUMESC ( p_param1 IN NUMBER, -- input-uri întâi p_param2 IN VARCHAR2, p_id_out OUT NUMBER, -- OUT NUMBER pentru ID-uri (întotdeauna ultimele) p_nrord_out OUT VARCHAR2 -- OUT VARCHAR2 pentru coduri string ) AS BEGIN -- Validare: aruncă în range 20001-20999 pentru erori business IF p_param1 NOT IN (SELECT id_tip FROM TIP_TU WHERE sters=0) THEN RAISE_APPLICATION_ERROR(-20001, 'Tip invalid: ' || p_param1); END IF; -- Logică: INSERT parent → INSERT child (RETURNING pentru FK chain) INSERT INTO PARENT_TABLE (...) VALUES (...) RETURNING id_parent INTO v_id_parent; INSERT INTO CHILD_TABLE (..., id_parent, ...) VALUES (..., v_id_parent, ...) RETURNING id_child INTO p_id_out; p_nrord_out := 'PREFIX-' || TO_CHAR(SEQ_TA.NEXTVAL); END; / ``` **Reguli SP**: - Range `RAISE_APPLICATION_ERROR`: `-20001` … `-20999` (map-at la HTTP 422) - Mesajele de eroare pot conține diacritice — oracledb le decodifică corect prin NLS chain - Nu folosiți `pack_sesiune.*` (state global per-sesiune) — folosiți `RETURNING INTO` local - SP-ul face `COMMIT` sau `ROLLBACK` explicit dacă e nevoie; Python face `commit()` după callproc --- ## 5. Python Callproc Pattern Codul exact din `services/comanda_service.py` — copiază și adaptează: ```python import oracledb from fastapi import HTTPException from shared.database.oracle_pool import oracle_pool from ..schemas.cerere import CerereRequest, CerereResponse from .. import logger def _handle_oracle_error(e: Exception) -> NoReturn: """Map Oracle error codes to HTTP. Always raises. Copy from comanda_service.py.""" err = e.args[0] code = getattr(err, "code", 0) raw_message = getattr(err, "message", str(e)) if 20001 <= code <= 20999: clean = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip() raise HTTPException(status_code=422, detail=clean) if code in (12541, 12170, 12154, 12560): raise HTTPException(status_code=503, detail="Serviciul bazei de date e temporar indisponibil") if code == 1017: logger.critical("Oracle credentials rejected (ORA-01017)", exc_info=True) raise HTTPException(status_code=500, detail="Eroare de configurare. Contactați administratorul.") if code == 942: logger.critical("Oracle object not found or grant missing (ORA-00942)", exc_info=True) raise HTTPException(status_code=500, detail="Eroare internă. Contactați administratorul.") logger.error("Unexpected Oracle error ORA-%05d", code, exc_info=True) raise HTTPException(status_code=500, detail="Eroare internă neașteptată") class CerereService: @staticmethod async def creeaza_cerere(data: CerereRequest, username: str) -> CerereResponse: logger.info("MODUL_NOU.creeaza_cerere START", extra={"user": username, "param1": data.param1}) async with oracle_pool.get_connection("MY_SERVER_ID") as connection: try: with connection.cursor() as cursor: out_id = cursor.var(oracledb.NUMBER) # OUT NUMBER → float → int() out_nrord = cursor.var(oracledb.STRING) # OUT VARCHAR2 cursor.callproc( "SCHEMA_TA.SP_NUMESC", [ data.param1, # p_param1 IN NUMBER data.param2, # p_param2 IN VARCHAR2 out_id, # p_id_out OUT NUMBER out_nrord, # p_nrord_out OUT VARCHAR2 ], ) connection.commit() result_id = int(out_id.getvalue()) # float → int result_nrord = out_nrord.getvalue() or "" except oracledb.DatabaseError as e: try: connection.rollback() except Exception: pass # conexiunea poate fi moartă pe erori de rețea _handle_oracle_error(e) logger.info("MODUL_NOU.creeaza_cerere OK", extra={"user": username, "id": result_id, "nrord": result_nrord}) return CerereResponse(id=result_id, nrord=result_nrord, mesaj=f"Cererea {result_nrord} creată cu succes.") ``` **Note critice**: - `out_id.getvalue()` returnează `float` — **obligatoriu `int()` cast** pentru ID-uri - `rollback()` în `except` poate arunca dacă conexiunea e moartă → `try/except pass` - `commit()` după callproc, nu înainte de `getvalue()` --- ## 6. Error Mapping Table | ORA code range | HTTP status | Interpretare | Acțiune log | |---|---|---|---| | 20001–20999 | 422 | Business rule (RAISE_APPLICATION_ERROR) | — (mesajul e user-friendly) | | 12541, 12170, 12154, 12560 | 503 | Oracle unreachable / TNS | — (infra issue) | | 1017 | 500 | Bad credentials — config error | `logger.critical` | | 942 | 500 | Object/grant missing — deployment error | `logger.critical` | | orice altceva | 500 | Eroare neașteptată | `logger.error` | Prefix `ORA-20XXX:` e **stripped** din mesajul business înainte de a-l trimite la client. Testele unit în `tests/test_error_mapping.py` verifică toate branch-urile fără live DB (mock `oracledb.DatabaseError` cu `MagicMock`). --- ## 7. Auth Reuse Fără cod nou — importă din shared: ```python from shared.auth.dependencies import get_current_user from shared.auth.models import CurrentUser @router.post("/cereri", response_model=CerereResponse) async def creeaza_cerere( data: CerereRequest, current_user: CurrentUser = Depends(get_current_user), ): return await CerereService.creeaza_cerere(data=data, username=current_user.username) ``` `AuthenticationMiddleware` (înregistrat global în `main.py`) injectează automat `request.state.user`. `get_current_user` extrage din `request.state.user` fără query DB. JWT conține `server_id` — accesibil ca `current_user.server_id` dacă ai nevoie să selectezi pool-ul dinamic per-user. --- ## 8. Ping / Health Endpoint Copiază din `routers/comanda.py` — schimbă `'mariusm_test'`: ```python import time import oracledb from fastapi import APIRouter, Depends, HTTPException from shared.auth.dependencies import get_current_user from shared.auth.models import CurrentUser from shared.database.oracle_pool import oracle_pool router = APIRouter() @router.get("/ping") async def ping(_: CurrentUser = Depends(get_current_user)): """Health check: verifică conectivitatea Oracle pentru server-ul modulului.""" t0 = time.perf_counter() try: async with oracle_pool.get_connection('MY_SERVER_ID') as conn: with conn.cursor() as cursor: cursor.execute('SELECT 1 FROM DUAL') row = cursor.fetchone() except oracledb.DatabaseError as e: raise HTTPException(status_code=503, detail=f"Oracle error: {e}") elapsed_ms = round((time.perf_counter() - t0) * 1000, 2) return {"result": row[0], "server": "MY_SERVER_ID", "latency_ms": elapsed_ms} ``` Ping-ul e **autentificat** (orice user valid) — nu e public. Latency așteptată pe server local: 20-35ms. Dacă > 500ms → tunel SSH sau rețea problemă. --- ## 9. Router Factory (`routers/__init__.py`) ```python from fastapi import APIRouter def create_MODUL_NOU_router() -> APIRouter: router = APIRouter() from .cerere import router as cerere_router router.include_router(cerere_router, tags=["MODUL-NOU"]) return router ``` Import lazy (în interiorul funcției) evită circular imports la load time. --- ## 10. `session_callback` — CURRENT_SCHEMA switching (opțional) Necesar dacă SP-urile tale sunt pe o schemă diferită de user-ul de conectare (ex. conectezi ca `ROA_WEB` dar SP-urile sunt în `SCHEMA_TA`): ```python # În modulul tău, înainte de register_server: def set_schema_callback(connection, requested_tag): """Apelat la fiecare acquire() din pool.""" with connection.cursor() as cur: cur.execute("ALTER SESSION SET CURRENT_SCHEMA = SCHEMA_TA") # La înregistrarea server-ului (sau în main.py, la init_oracle_pool): oracle_pool.register_server( server_id="MY_SERVER_ID", host="...", port=1521, user="ROA_WEB", password="...", service_name="ROA", session_callback=set_schema_callback, # ← patch disponibil în oracle_pool.py ) ``` `oracle_pool.register_server()` acceptă deja `session_callback` (implementat în `shared/database/oracle_pool.py:66` și `:133-134`). Zero modificări la shared code. > **Concurrency caveat**: testul de concurență pe 1 schemă e pending (ipoteza #2). > Pattern-ul e safe pentru 1 schemă per pool. Multi-schemă pe același pool necesită > tagged connections — deferrat pentru phase 2. --- ## 11. Checklist Rapid ``` □ .env: entry nou în ORACLE_SERVERS (id, host, port, user, service_name) □ backend/secrets/MY_SERVER_ID.oracle_pass creat □ Oracle: GRANT CREATE SESSION + EXECUTE ON SP + SELECT ON views (sau folosești DBA user pentru prototype) □ __init__.py: logger cu propagate=False + FileHandler absolut □ schemas/: Request + Response (câmpuri Pydantic, zero logică) □ services/: _handle_oracle_error() + clasa Service cu callproc + int(out.getvalue()) □ routers/__init__.py: create_MODUL_router() factory cu lazy import □ routers/cerere.py: /ping endpoint autentificat + endpoint-uri business □ main.py: import factory + app.include_router cu prefix + tags □ tests/test_error_mapping.py: unit tests fără live DB (mock DatabaseError) □ Testează /ping: latency < 35ms → Oracle reachabil ```