update: new class
This commit is contained in:
parent
e02aca560c
commit
0612667b22
13 changed files with 836 additions and 87 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -209,3 +209,4 @@ __marimo__/
|
|||
# other
|
||||
|
||||
pdfs/
|
||||
streamlit/
|
||||
61
CLAUDE.md
61
CLAUDE.md
|
|
@ -26,10 +26,15 @@ src/pif_compiler/
|
|||
│ └── routes/
|
||||
│ ├── api_echa.py # ECHA endpoints (single + batch search)
|
||||
│ ├── api_cosing.py # COSING endpoints (single + batch search)
|
||||
│ ├── api_ingredients.py # Ingredient search by CAS + list all ingested
|
||||
│ ├── api_esposition.py # Esposition preset creation + list all presets
|
||||
│ └── common.py # PDF generation, PubChem, CIR search endpoints
|
||||
├── classes/
|
||||
│ └── models.py # Pydantic models: Ingredient, DapInfo, CosingInfo,
|
||||
│ # ToxIndicator, Toxicity, Esposition, RetentionFactors
|
||||
│ ├── __init__.py # Re-exports all models from models.py and main_cls.py
|
||||
│ ├── models.py # Pydantic models: Ingredient, DapInfo, CosingInfo,
|
||||
│ │ # ToxIndicator, Toxicity, Esposition, RetentionFactors, StatoOrdine
|
||||
│ └── main_cls.py # Orchestrator classes: Order (raw input layer),
|
||||
│ # Project (processed layer), IngredientInput
|
||||
├── functions/
|
||||
│ ├── common_func.py # PDF generation with Playwright
|
||||
│ ├── common_log.py # Centralized logging configuration
|
||||
|
|
@ -44,9 +49,10 @@ src/pif_compiler/
|
|||
|
||||
### Other directories
|
||||
|
||||
- `data/` - Input data files (`input.json` with sample INCI/CAS/percentage lists), old CSV data
|
||||
- `data/` - Input data files (`input.json` with sample INCI/CAS/percentage lists), DB schema reference (`db_schema.sql`), old CSV data
|
||||
- `logs/` - Rotating log files (debug.log, error.log) - auto-generated
|
||||
- `pdfs/` - Generated PDF files from ECHA dossier pages
|
||||
- `streamlit/` - Streamlit UI pages (`ingredients_page.py`, `exposition_page.py`)
|
||||
- `marimo/` - **Ignore this folder.** Debug/test notebooks, not part of the main application
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
|
@ -60,10 +66,32 @@ src/pif_compiler/
|
|||
6. **Toxicity ranking** (`Toxicity` model): Best toxicological indicator selection with priority (NOAEL > LOAEL > LD50) and safety factors
|
||||
|
||||
### Caching strategy
|
||||
- ECHA results are cached in MongoDB (`toxinfo.substance_index` collection) keyed by `substance.rmlCas`
|
||||
- **ECHA results** are cached in MongoDB (`toxinfo.substance_index` collection) keyed by `substance.rmlCas`
|
||||
- **Ingredients** are cached in MongoDB (`toxinfo.ingredients` collection) keyed by `cas`, with PostgreSQL `ingredienti` table as index (stores `mongo_id` + enrichment flags `dap`, `cosing`, `tox`)
|
||||
- **Orders** are cached in MongoDB (`toxinfo.orders` collection) keyed by `uuid_ordine`
|
||||
- **Projects** are cached in MongoDB (`toxinfo.projects` collection) keyed by `uuid_progetto`
|
||||
- The orchestrator checks local cache before making external requests
|
||||
- `Ingredient.get_or_create(cas)` checks PostgreSQL -> MongoDB cache, returns cached if not older than 365 days, otherwise re-scrapes
|
||||
- Search history is logged to PostgreSQL (`logs.search_history` table)
|
||||
|
||||
### Order / Project architecture
|
||||
- **Order** (`main_cls.py`): Raw input layer. Receives JSON with client, compiler, product type, ingredients list (CAS + percentage). Cleans CAS numbers (strips `\n`, splits by `;`). Saves to MongoDB `orders` collection. Registers client/compiler in PostgreSQL.
|
||||
- **Project** (`main_cls.py`): Processed layer. Created from an Order via `Project.from_order()`. Holds enriched `Ingredient` objects, percentages mapping (CAS -> %), and `Esposition` preset. `process_ingredients()` calls `Ingredient.get_or_create()` for each CAS. Saves to MongoDB `projects` collection.
|
||||
- An order can update an older project — they are decoupled.
|
||||
|
||||
### PostgreSQL schema (see `data/db_schema.sql`)
|
||||
|
||||
- **`clienti`** - Customers (`id_cliente`, `nome_cliente`)
|
||||
- **`compilatori`** - PIF compilers/assessors (`id_compilatore`, `nome_compilatore`)
|
||||
- **`ordini`** - Orders linking a client + compiler to a project (`uuid_ordine`, `uuid_progetto`, `data_ordine`, `stato_ordine`). FK to `clienti`, `compilatori`, `stati_ordini`
|
||||
- **`stati_ordini`** - Order status lookup table (`id_stato`, `nome_stato`). Values mapped to `StatoOrdine` IntEnum in `models.py`
|
||||
- **`ingredienti`** - Ingredient registry keyed by CAS. Tracks enrichment status via boolean flags (`dap`, `cosing`, `tox`) and links to MongoDB document (`mongo_id`)
|
||||
- **`inci`** - INCI name to CAS mapping. FK to `ingredienti(cas)`
|
||||
- **`progetti`** - Projects linked to an order (`mongo_id` -> `ordini.uuid_progetto`) and a product type preset (`preset_tipo_prodotto` -> `tipi_prodotti`)
|
||||
- **`ingredients_lineage`** - Many-to-many join between `progetti` and `ingredienti`, tracking which ingredients belong to which project
|
||||
- **`tipi_prodotti`** - Product type presets with exposure parameters (`preset_name`, `tipo_prodotto`, `luogo_applicazione`, exposure routes, `sup_esposta`, `freq_applicazione`, `qta_giornaliera`, `ritenzione`). Maps to the `Esposition` Pydantic model
|
||||
- **`logs.search_history`** - Search audit log (`cas_ricercato`, `target`, `esito`)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All routes are under `/api/v1`:
|
||||
|
|
@ -74,6 +102,10 @@ All routes are under `/api/v1`:
|
|||
| POST | `/echa/batch-search` | Batch ECHA search for multiple CAS numbers |
|
||||
| POST | `/cosing/search` | COSING search (by name, CAS, EC, or ID) |
|
||||
| POST | `/cosing/batch-search` | Batch COSING search |
|
||||
| POST | `/ingredients/search` | Get full ingredient by CAS (cached or scraped) |
|
||||
| GET | `/ingredients/list` | List all ingested ingredients from PostgreSQL |
|
||||
| POST | `/esposition/create` | Create a new esposition preset |
|
||||
| GET | `/esposition/presets` | List all esposition presets |
|
||||
| POST | `/common/pubchem` | PubChem property lookup by CAS |
|
||||
| POST | `/common/generate-pdf` | Generate PDF from URL via Playwright |
|
||||
| GET | `/common/download-pdf/{name}` | Download a generated PDF |
|
||||
|
|
@ -110,10 +142,31 @@ uv run uvicorn pif_compiler.main:app --reload --host 0.0.0.0 --port 8000
|
|||
### Key conventions
|
||||
- Services in `services/` handle external API calls and data extraction
|
||||
- Models in `classes/models.py` use Pydantic `@model_validator` and `@classmethod` builders for construction from raw API data
|
||||
- Orchestrator classes in `classes/main_cls.py` handle Order (raw input) and Project (processed) layers
|
||||
- The `orchestrator` pattern (see `srv_echa.py`) handles: validate input -> check local cache -> fetch from external -> store locally -> return
|
||||
- `Ingredient.ingredient_builder(cas)` calls scraping functions directly (`pubchem_dap`, `cosing_entry`, `orchestrator`)
|
||||
- `Ingredient.save()` upserts to both MongoDB and PostgreSQL, `Ingredient.from_cas()` retrieves via PostgreSQL index -> MongoDB
|
||||
- `Ingredient.get_or_create(cas)` is the main entry point: checks cache freshness (365 days), scrapes if needed
|
||||
- All modules use the shared logger from `common_log.get_logger()`
|
||||
- API routes define Pydantic request/response models inline in each route file
|
||||
|
||||
### db_utils.py functions
|
||||
- `db_connect(db_name, collection_name)` - MongoDB collection accessor
|
||||
- `postgres_connect()` - PostgreSQL connection
|
||||
- `upsert_ingrediente(cas, mongo_id, dap, cosing, tox)` - Upsert ingredient in PostgreSQL
|
||||
- `get_ingrediente_by_cas(cas)` - Get ingredient row by CAS
|
||||
- `get_all_ingredienti()` - List all ingredients from PostgreSQL
|
||||
- `upsert_cliente(nome_cliente)` - Upsert client, returns `id_cliente`
|
||||
- `upsert_compilatore(nome_compilatore)` - Upsert compiler, returns `id_compilatore`
|
||||
- `aggiorna_stato_ordine(id_ordine, nuovo_stato)` - Update order status
|
||||
- `log_ricerche(cas, target, esito)` - Log search history
|
||||
|
||||
### Streamlit UI
|
||||
- `streamlit/ingredients_page.py` - Ingredient search by CAS + result display + inventory of ingested ingredients
|
||||
- `streamlit/exposition_page.py` - Esposition preset creation form + list of existing presets
|
||||
- Both pages call the FastAPI endpoints via `requests` (API must be running on `localhost:8000`)
|
||||
- Run with: `streamlit run streamlit/<page>.py`
|
||||
|
||||
### Important domain concepts
|
||||
- **CAS number**: Chemical Abstracts Service identifier (e.g., "50-00-0")
|
||||
- **INCI**: International Nomenclature of Cosmetic Ingredients
|
||||
|
|
|
|||
82
data/db_schema.sql
Normal file
82
data/db_schema.sql
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
-- WARNING: This schema is for context only and is not meant to be run.
|
||||
-- Table order and constraints may not be valid for execution.
|
||||
|
||||
CREATE TABLE public.clienti (
|
||||
id_cliente integer NOT NULL DEFAULT nextval('clienti_id_cliente_seq'::regclass),
|
||||
nome_cliente character varying NOT NULL UNIQUE,
|
||||
CONSTRAINT clienti_pkey PRIMARY KEY (id_cliente)
|
||||
);
|
||||
CREATE TABLE public.compilatori (
|
||||
id_compilatore integer NOT NULL DEFAULT nextval('compilatori_id_compilatore_seq'::regclass),
|
||||
nome_compilatore character varying NOT NULL UNIQUE,
|
||||
CONSTRAINT compilatori_pkey PRIMARY KEY (id_compilatore)
|
||||
);
|
||||
CREATE TABLE public.inci (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
inci text NOT NULL UNIQUE,
|
||||
cas text NOT NULL,
|
||||
CONSTRAINT inci_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT inci_cas_fkey FOREIGN KEY (cas) REFERENCES public.ingredienti(cas)
|
||||
);
|
||||
CREATE TABLE public.ingredienti (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
cas text NOT NULL UNIQUE,
|
||||
mongo_id text,
|
||||
dap boolean DEFAULT false,
|
||||
cosing boolean DEFAULT false,
|
||||
tox boolean DEFAULT false,
|
||||
CONSTRAINT ingredienti_pkey PRIMARY KEY (id)
|
||||
);
|
||||
CREATE TABLE public.ingredients_lineage (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
id_progetto integer,
|
||||
id_ingrediente bigint,
|
||||
CONSTRAINT ingredients_lineage_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT ingredients_lineage_id_ingrediente_fkey FOREIGN KEY (id_ingrediente) REFERENCES public.ingredienti(id),
|
||||
CONSTRAINT ingredients_lineage_id_progetto_fkey FOREIGN KEY (id_progetto) REFERENCES public.progetti(id)
|
||||
);
|
||||
CREATE TABLE public.ordini (
|
||||
id_ordine integer NOT NULL DEFAULT nextval('ordini_id_ordine_seq'::regclass),
|
||||
id_cliente integer,
|
||||
id_compilatore integer,
|
||||
uuid_ordine character varying NOT NULL,
|
||||
uuid_progetto character varying NOT NULL UNIQUE,
|
||||
data_ordine timestamp without time zone NOT NULL,
|
||||
stato_ordine integer DEFAULT 1,
|
||||
note text,
|
||||
CONSTRAINT ordini_pkey PRIMARY KEY (id_ordine),
|
||||
CONSTRAINT ordini_id_cliente_fkey FOREIGN KEY (id_cliente) REFERENCES public.clienti(id_cliente),
|
||||
CONSTRAINT ordini_id_compilatore_fkey FOREIGN KEY (id_compilatore) REFERENCES public.compilatori(id_compilatore),
|
||||
CONSTRAINT ordini_stato_ordine_fkey FOREIGN KEY (stato_ordine) REFERENCES public.stati_ordini(id_stato)
|
||||
);
|
||||
CREATE TABLE public.progetti (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
mongo_id character varying,
|
||||
preset_tipo_prodotto integer,
|
||||
CONSTRAINT progetti_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT progetti_mongo_id_fkey FOREIGN KEY (mongo_id) REFERENCES public.ordini(uuid_progetto),
|
||||
CONSTRAINT progetti_preset_tipo_prodotto_fkey FOREIGN KEY (preset_tipo_prodotto) REFERENCES public.tipi_prodotti(id_preset)
|
||||
);
|
||||
CREATE TABLE public.stati_ordini (
|
||||
id_stato integer NOT NULL DEFAULT nextval('stati_ordini_id_stato_seq'::regclass),
|
||||
nome_stato character varying NOT NULL,
|
||||
CONSTRAINT stati_ordini_pkey PRIMARY KEY (id_stato)
|
||||
);
|
||||
CREATE TABLE public.tipi_prodotti (
|
||||
id_preset integer NOT NULL DEFAULT nextval('tipi_prodotti_id_tipo_seq'::regclass),
|
||||
preset_name text NOT NULL UNIQUE,
|
||||
tipo_prodotto text,
|
||||
luogo_applicazione text,
|
||||
esp_normali text,
|
||||
esp_secondarie text,
|
||||
esp_nano text NOT NULL,
|
||||
sup_esposta integer,
|
||||
freq_applicazione integer,
|
||||
qta_giornaliera double precision,
|
||||
ritenzione double precision,
|
||||
CONSTRAINT tipi_prodotti_pkey PRIMARY KEY (id_preset)
|
||||
);
|
||||
11
data/insert.sql
Normal file
11
data/insert.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
INSERT INTO public.stati_ordini (id_stato, nome_stato) VALUES
|
||||
(1, 'RICEVUTO'),
|
||||
(2, 'VALIDATO'),
|
||||
(3, 'ARRICCHIMENTO'),
|
||||
(4, 'ARRICCHIMENTO_PARZIALE'),
|
||||
(5, 'ARRICCHITO'),
|
||||
(6, 'CALCOLO'),
|
||||
(7, 'IN_REVISIONE'),
|
||||
(8, 'COMPLETATO'),
|
||||
(9, 'ERRORE'),
|
||||
(10, 'ANNULLATO');
|
||||
|
|
@ -12,19 +12,38 @@ def _():
|
|||
|
||||
@app.cell
|
||||
def _():
|
||||
from pif_compiler.services.srv_cosing import cosing_search
|
||||
return (cosing_search,)
|
||||
from pif_compiler.services.srv_cosing import cosing_entry
|
||||
from pif_compiler.classes.models import CosingInfo
|
||||
return CosingInfo, cosing_entry
|
||||
|
||||
|
||||
@app.cell
|
||||
def _():
|
||||
cas = ' 9006-65-9 '
|
||||
cas = '64-17-5'
|
||||
return (cas,)
|
||||
|
||||
|
||||
@app.cell
|
||||
def _(cas, cosing_search):
|
||||
cosing_search(cas, mode='cas')
|
||||
def _(cas, cosing_entry):
|
||||
data = cosing_entry(cas)
|
||||
return (data,)
|
||||
|
||||
|
||||
@app.cell
|
||||
def _(data):
|
||||
data
|
||||
return
|
||||
|
||||
|
||||
@app.cell
|
||||
def _(CosingInfo, data):
|
||||
test = CosingInfo(**data)
|
||||
return (test,)
|
||||
|
||||
|
||||
@app.cell
|
||||
def _(test):
|
||||
test
|
||||
return
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,42 +6,19 @@ app = marimo.App(width="medium")
|
|||
|
||||
@app.cell
|
||||
def _():
|
||||
from pif_compiler.classes.models import Esposition
|
||||
return (Esposition,)
|
||||
from pif_compiler.classes.models import Ingredient
|
||||
|
||||
# Costruisce l'ingrediente da scraping e lo salva
|
||||
ing = Ingredient.ingredient_builder("64-17-5", inci=["ALCOHOL"])
|
||||
print(f"CAS: {ing.cas}")
|
||||
print(f"DAP: {ing.dap_info}")
|
||||
print(f"COSING: {ing.cosing_info is not None}")
|
||||
print(f"TOX: {ing.toxicity}")
|
||||
print(f"Stats: {ing.get_stats()}")
|
||||
|
||||
@app.cell
|
||||
def _(Esposition):
|
||||
it = Esposition(
|
||||
preset_name="Test xzzx<xdsadsa<",
|
||||
tipo_prodotto="Test Product",
|
||||
luogo_applicazione="Face",
|
||||
esp_normali=["DERMAL", "ASD"],
|
||||
esp_secondarie=["ORAL"],
|
||||
esp_nano=["NA"],
|
||||
sup_esposta=500,
|
||||
freq_applicazione=2,
|
||||
qta_giornaliera=1.5,
|
||||
ritenzione=0.1
|
||||
)
|
||||
return (it,)
|
||||
|
||||
|
||||
@app.cell
|
||||
def _(it):
|
||||
it.save_to_postgres()
|
||||
return
|
||||
|
||||
|
||||
@app.cell
|
||||
def _(Esposition):
|
||||
data = Esposition.get_presets()
|
||||
return (data,)
|
||||
|
||||
|
||||
@app.cell
|
||||
def _(data):
|
||||
data
|
||||
# Salva su MongoDB + PostgreSQL
|
||||
mongo_id = ing.save()
|
||||
print(f"Salvato con mongo_id: {mongo_id}")
|
||||
return
|
||||
|
||||
|
||||
|
|
|
|||
104
src/pif_compiler/api/routes/api_esposition.py
Normal file
104
src/pif_compiler/api/routes/api_esposition.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
from pif_compiler.classes.models import Esposition
|
||||
from pif_compiler.functions.common_log import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class EspositionRequest(BaseModel):
|
||||
preset_name: str = Field(..., description="Nome del preset")
|
||||
tipo_prodotto: str = Field(..., description="Tipo di prodotto cosmetico")
|
||||
luogo_applicazione: str = Field(..., description="Luogo di applicazione")
|
||||
esp_normali: List[str] = Field(..., description="Vie di esposizione normali")
|
||||
esp_secondarie: List[str] = Field(..., description="Vie di esposizione secondarie")
|
||||
esp_nano: List[str] = Field(..., description="Vie di esposizione nano")
|
||||
sup_esposta: int = Field(..., ge=1, le=17500, description="Area di applicazione in cm2")
|
||||
freq_applicazione: int = Field(default=1, description="Numero di applicazioni al giorno")
|
||||
qta_giornaliera: float = Field(..., description="Quantità di prodotto applicata (g/die)")
|
||||
ritenzione: float = Field(default=1.0, ge=0, le=1.0, description="Fattore di ritenzione")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"preset_name": "Crema viso leave-on",
|
||||
"tipo_prodotto": "Crema",
|
||||
"luogo_applicazione": "Viso",
|
||||
"esp_normali": ["Dermal"],
|
||||
"esp_secondarie": ["Oral", "Inhalation"],
|
||||
"esp_nano": ["Dermal"],
|
||||
"sup_esposta": 565,
|
||||
"freq_applicazione": 2,
|
||||
"qta_giornaliera": 1.54,
|
||||
"ritenzione": 1.0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class EspositionResponse(BaseModel):
|
||||
success: bool
|
||||
data: Optional[dict] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class EspositionListResponse(BaseModel):
|
||||
success: bool
|
||||
total: int
|
||||
data: Optional[List[dict]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/esposition/create", response_model=EspositionResponse, tags=["Esposition"])
|
||||
async def create_esposition(request: EspositionRequest):
|
||||
"""Crea un nuovo preset di esposizione e lo salva su PostgreSQL."""
|
||||
logger.info(f"Creazione preset esposizione: {request.preset_name}")
|
||||
|
||||
try:
|
||||
esposition = Esposition(**request.model_dump())
|
||||
id_preset = esposition.save_to_postgres()
|
||||
|
||||
if not id_preset:
|
||||
logger.warning(f"Salvataggio fallito per preset: {request.preset_name}")
|
||||
return EspositionResponse(
|
||||
success=False,
|
||||
error=f"Errore nel salvataggio del preset '{request.preset_name}'"
|
||||
)
|
||||
|
||||
logger.info(f"Preset '{request.preset_name}' creato con id_preset={id_preset}")
|
||||
return EspositionResponse(
|
||||
success=True,
|
||||
data={"id_preset": id_preset, **esposition.model_dump()}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Errore creazione preset {request.preset_name}: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Errore interno: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/esposition/presets", response_model=EspositionListResponse, tags=["Esposition"])
|
||||
async def get_all_presets():
|
||||
"""Recupera tutti i preset di esposizione da PostgreSQL."""
|
||||
logger.info("Recupero tutti i preset di esposizione")
|
||||
|
||||
try:
|
||||
presets = Esposition.get_presets()
|
||||
|
||||
logger.info(f"Recuperati {len(presets)} preset")
|
||||
return EspositionListResponse(
|
||||
success=True,
|
||||
total=len(presets),
|
||||
data=[p.model_dump() for p in presets]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Errore recupero preset: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Errore interno: {str(e)}"
|
||||
)
|
||||
102
src/pif_compiler/api/routes/api_ingredients.py
Normal file
102
src/pif_compiler/api/routes/api_ingredients.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from pif_compiler.classes.models import Ingredient
|
||||
from pif_compiler.functions.db_utils import get_all_ingredienti
|
||||
from pif_compiler.functions.common_log import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class IngredientRequest(BaseModel):
|
||||
cas: str = Field(..., description="CAS number dell'ingrediente da cercare")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"cas": "56-81-5"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class IngredientResponse(BaseModel):
|
||||
success: bool
|
||||
cas: str
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/ingredients/search", response_model=IngredientResponse, tags=["Ingredients"])
|
||||
async def get_ingredient(request: IngredientRequest):
|
||||
"""Recupera un ingrediente per CAS. Se esiste in cache lo restituisce, altrimenti lo crea da scraping."""
|
||||
logger.info(f"Richiesta ingrediente per CAS: {request.cas}")
|
||||
|
||||
try:
|
||||
ingredient = Ingredient.get_or_create(request.cas)
|
||||
|
||||
if ingredient is None:
|
||||
logger.warning(f"Nessun dato trovato per CAS: {request.cas}")
|
||||
return IngredientResponse(
|
||||
success=False,
|
||||
cas=request.cas,
|
||||
error=f"Nessun dato trovato per CAS: {request.cas}"
|
||||
)
|
||||
|
||||
logger.info(f"Ingrediente {request.cas} recuperato con successo")
|
||||
return IngredientResponse(
|
||||
success=True,
|
||||
cas=request.cas,
|
||||
data=ingredient.model_dump()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Errore recupero ingrediente {request.cas}: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Errore interno: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
class IngredientListResponse(BaseModel):
|
||||
success: bool
|
||||
total: int
|
||||
data: Optional[List[Dict[str, Any]]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/ingredients/list", response_model=IngredientListResponse, tags=["Ingredients"])
|
||||
async def list_ingredients():
|
||||
"""Restituisce tutti gli ingredienti gia presenti nella tabella ingredienti di PostgreSQL."""
|
||||
logger.info("Recupero lista ingredienti dal DB")
|
||||
|
||||
try:
|
||||
rows = get_all_ingredienti()
|
||||
|
||||
ingredients = []
|
||||
for row in rows:
|
||||
id_, cas, mongo_id, dap, cosing, tox, created_at = row
|
||||
ingredients.append({
|
||||
"id": id_,
|
||||
"cas": cas,
|
||||
"mongo_id": mongo_id,
|
||||
"dap": dap,
|
||||
"cosing": cosing,
|
||||
"tox": tox,
|
||||
"created_at": created_at.isoformat() if created_at else None,
|
||||
})
|
||||
|
||||
logger.info(f"Recuperati {len(ingredients)} ingredienti")
|
||||
return IngredientListResponse(
|
||||
success=True,
|
||||
total=len(ingredients),
|
||||
data=ingredients,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Errore recupero lista ingredienti: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Errore interno: {str(e)}"
|
||||
)
|
||||
|
|
@ -5,38 +5,32 @@ This module contains all data models for the PIF (Product Information File) syst
|
|||
"""
|
||||
|
||||
from pif_compiler.classes.models import (
|
||||
StatoOrdine,
|
||||
DapInfo,
|
||||
CosingInfo,
|
||||
ToxIndicator,
|
||||
Toxicity,
|
||||
Ingredient,
|
||||
ExpositionInfo,
|
||||
SedTable,
|
||||
ProdCompany,
|
||||
RetentionFactors,
|
||||
Esposition,
|
||||
)
|
||||
|
||||
from pif_compiler.classes.pif_class import PIF
|
||||
|
||||
from pif_compiler.classes.types_enum import (
|
||||
CosmeticType,
|
||||
PhysicalForm,
|
||||
PlaceApplication,
|
||||
NormalUser,
|
||||
RoutesExposure,
|
||||
NanoRoutes,
|
||||
TranslatedEnum,
|
||||
from pif_compiler.classes.main_cls import (
|
||||
IngredientInput,
|
||||
Order,
|
||||
Project,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Main PIF model
|
||||
"PIF",
|
||||
# Component models
|
||||
"StatoOrdine",
|
||||
"DapInfo",
|
||||
"CosingInfo",
|
||||
"ToxIndicator",
|
||||
"Toxicity",
|
||||
"Ingredient",
|
||||
"ExpositionInfo",
|
||||
"SedTable",
|
||||
"ProdCompany",
|
||||
# Enums
|
||||
"CosmeticType",
|
||||
"PhysicalForm",
|
||||
"PlaceApplication",
|
||||
"NormalUser",
|
||||
"RoutesExposure",
|
||||
"NanoRoutes",
|
||||
"TranslatedEnum",
|
||||
"RetentionFactors",
|
||||
"Esposition",
|
||||
"IngredientInput",
|
||||
"Order",
|
||||
"Project",
|
||||
]
|
||||
|
|
|
|||
209
src/pif_compiler/classes/main_cls.py
Normal file
209
src/pif_compiler/classes/main_cls.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime as dt
|
||||
|
||||
from pif_compiler.classes.models import (
|
||||
StatoOrdine,
|
||||
Ingredient,
|
||||
Esposition,
|
||||
)
|
||||
|
||||
from pif_compiler.functions.db_utils import (
|
||||
db_connect,
|
||||
upsert_cliente,
|
||||
upsert_compilatore,
|
||||
)
|
||||
from pif_compiler.functions.common_log import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class IngredientInput(BaseModel):
|
||||
cas_raw: str
|
||||
cas_list: List[str] = Field(default_factory=list)
|
||||
percentage: float
|
||||
|
||||
@model_validator(mode='after')
|
||||
def clean_cas(self):
|
||||
cleaned = self.cas_raw.replace('\n', '')
|
||||
self.cas_list = [c.strip() for c in cleaned.split(';') if c.strip()]
|
||||
return self
|
||||
|
||||
|
||||
class Order(BaseModel):
|
||||
"""Layer grezzo: riceve l'input, lo valida e lo salva su MongoDB."""
|
||||
uuid_ordine: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
client_name: str
|
||||
compiler_name: str
|
||||
product_type: str
|
||||
date: str
|
||||
notes: str = ""
|
||||
stato: StatoOrdine = StatoOrdine.RICEVUTO
|
||||
|
||||
ingredients_input: List[IngredientInput]
|
||||
total_percentage: float = 0
|
||||
num_ingredients: int = 0
|
||||
created_at: Optional[str] = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def set_defaults(self):
|
||||
if self.created_at is None:
|
||||
self.created_at = dt.now().isoformat()
|
||||
if self.total_percentage == 0:
|
||||
self.total_percentage = sum(i.percentage for i in self.ingredients_input)
|
||||
if self.num_ingredients == 0:
|
||||
self.num_ingredients = len(self.ingredients_input)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_input(cls, data: dict):
|
||||
"""Costruisce un Order a partire dal JSON grezzo di input."""
|
||||
ingredients_input = []
|
||||
for ing in data.get('ingredients', []):
|
||||
ingredients_input.append(IngredientInput(
|
||||
cas_raw=ing.get('CAS Number', ''),
|
||||
percentage=ing.get('Percentage (%)', 0)
|
||||
))
|
||||
|
||||
return cls(
|
||||
client_name=data.get('client_name', ''),
|
||||
compiler_name=data.get('compiler_name', ''),
|
||||
product_type=data.get('product_type', ''),
|
||||
date=data.get('date', ''),
|
||||
notes=data.get('notes', ''),
|
||||
ingredients_input=ingredients_input,
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Salva l'ordine su MongoDB (collection 'orders'). Ritorna il mongo_id."""
|
||||
collection = db_connect(collection_name='orders')
|
||||
mongo_dict = self.model_dump()
|
||||
|
||||
result = collection.replace_one(
|
||||
{"uuid_ordine": self.uuid_ordine},
|
||||
mongo_dict,
|
||||
upsert=True
|
||||
)
|
||||
|
||||
if result.upserted_id:
|
||||
mongo_id = str(result.upserted_id)
|
||||
else:
|
||||
doc = collection.find_one({"uuid_ordine": self.uuid_ordine}, {"_id": 1})
|
||||
mongo_id = str(doc["_id"])
|
||||
|
||||
logger.info(f"Ordine {self.uuid_ordine} salvato su MongoDB: {mongo_id}")
|
||||
return mongo_id
|
||||
|
||||
def register(self):
|
||||
"""Registra cliente e compilatore su PostgreSQL. Ritorna (id_cliente, id_compilatore)."""
|
||||
id_cliente = upsert_cliente(self.client_name)
|
||||
id_compilatore = upsert_compilatore(self.compiler_name)
|
||||
logger.info(f"Registrato cliente={id_cliente}, compilatore={id_compilatore}")
|
||||
return id_cliente, id_compilatore
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
"""Layer elaborato: contiene gli ingredienti arricchiti, l'esposizione e le statistiche."""
|
||||
uuid_progetto: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
uuid_ordine: str
|
||||
stato: StatoOrdine = StatoOrdine.VALIDATO
|
||||
|
||||
ingredients: List[Ingredient] = Field(default_factory=list)
|
||||
percentages: Dict[str, float] = Field(default_factory=dict)
|
||||
esposition: Optional[Esposition] = None
|
||||
|
||||
created_at: Optional[str] = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def set_created_at(self):
|
||||
if self.created_at is None:
|
||||
self.created_at = dt.now().isoformat()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_order(cls, order: Order):
|
||||
"""Crea un progetto a partire da un ordine, estraendo la lista CAS e le percentuali."""
|
||||
percentages = {}
|
||||
for ing_input in order.ingredients_input:
|
||||
for cas in ing_input.cas_list:
|
||||
percentages[cas] = ing_input.percentage
|
||||
|
||||
return cls(
|
||||
uuid_ordine=order.uuid_ordine,
|
||||
percentages=percentages,
|
||||
)
|
||||
|
||||
def process_ingredients(self):
|
||||
"""Arricchisce tutti gli ingredienti tramite Ingredient.get_or_create."""
|
||||
self.stato = StatoOrdine.ARRICCHIMENTO
|
||||
self.ingredients = []
|
||||
errori = 0
|
||||
|
||||
for cas in self.percentages:
|
||||
try:
|
||||
ingredient = Ingredient.get_or_create(cas)
|
||||
self.ingredients.append(ingredient)
|
||||
logger.info(f"Ingrediente {cas} processato")
|
||||
except Exception as e:
|
||||
logger.error(f"Errore processando CAS {cas}: {e}")
|
||||
errori += 1
|
||||
|
||||
self.stato = StatoOrdine.ARRICCHIMENTO_PARZIALE if errori > 0 else StatoOrdine.ARRICCHITO
|
||||
self.save()
|
||||
return self.ingredients
|
||||
|
||||
def set_esposition(self, preset_name: str):
|
||||
"""Carica un preset di esposizione da PostgreSQL per nome."""
|
||||
presets = Esposition.get_presets()
|
||||
for p in presets:
|
||||
if p.preset_name == preset_name:
|
||||
self.esposition = p
|
||||
return p
|
||||
logger.warning(f"Preset '{preset_name}' non trovato")
|
||||
return None
|
||||
|
||||
def save(self):
|
||||
"""Salva il progetto su MongoDB (collection 'projects'). Ritorna il mongo_id."""
|
||||
collection = db_connect(collection_name='projects')
|
||||
mongo_dict = self.model_dump()
|
||||
|
||||
result = collection.replace_one(
|
||||
{"uuid_progetto": self.uuid_progetto},
|
||||
mongo_dict,
|
||||
upsert=True
|
||||
)
|
||||
|
||||
if result.upserted_id:
|
||||
mongo_id = str(result.upserted_id)
|
||||
else:
|
||||
doc = collection.find_one({"uuid_progetto": self.uuid_progetto}, {"_id": 1})
|
||||
mongo_id = str(doc["_id"])
|
||||
|
||||
logger.info(f"Progetto {self.uuid_progetto} salvato su MongoDB: {mongo_id}")
|
||||
return mongo_id
|
||||
|
||||
def get_stats(self):
|
||||
"""Ritorna statistiche sul progetto e sullo stato di arricchimento."""
|
||||
stats = {
|
||||
"uuid_progetto": self.uuid_progetto,
|
||||
"uuid_ordine": self.uuid_ordine,
|
||||
"stato": self.stato.name,
|
||||
"has_esposition": self.esposition is not None,
|
||||
"num_ingredients": len(self.ingredients),
|
||||
"num_cas_input": len(self.percentages),
|
||||
}
|
||||
|
||||
if self.ingredients:
|
||||
stats["enrichment"] = {
|
||||
"with_dap": sum(1 for i in self.ingredients if i.dap_info is not None),
|
||||
"with_cosing": sum(1 for i in self.ingredients if i.cosing_info is not None),
|
||||
"with_tox": sum(1 for i in self.ingredients if i.toxicity is not None),
|
||||
"with_noael": sum(
|
||||
1 for i in self.ingredients
|
||||
if i.toxicity and any(ind.indicator == 'NOAEL' for ind in i.toxicity.indicators)
|
||||
),
|
||||
}
|
||||
|
||||
return stats
|
||||
|
|
@ -1,10 +1,24 @@
|
|||
from pydantic import BaseModel, Field, field_validator, ConfigDict, model_validator, computed_field
|
||||
import re
|
||||
from enum import IntEnum
|
||||
from typing import List, Optional
|
||||
from datetime import datetime as dt
|
||||
|
||||
class StatoOrdine(IntEnum):
|
||||
"""Stati ordine per orchestrare il flusso di elaborazione PIF."""
|
||||
RICEVUTO = 1 # Input grezzo ricevuto, caricato su MongoDB
|
||||
VALIDATO = 2 # Input validato (compilatore, cliente, tipo cosmetico ok)
|
||||
ARRICCHIMENTO = 3 # Arricchimento in corso (COSING, PubChem, ECHA)
|
||||
ARRICCHIMENTO_PARZIALE = 4 # Arricchimento completato ma con dati mancanti
|
||||
ARRICCHITO = 5 # Arricchimento completato con successo
|
||||
CALCOLO = 6 # Calcolo DAP, SED, MoS in corso
|
||||
IN_REVISIONE = 7 # Calcoli completati, in attesa di revisione umana
|
||||
COMPLETATO = 8 # PIF finalizzato
|
||||
ERRORE = 9 # Errore durante l'elaborazione
|
||||
ANNULLATO = 10 # Ordine annullato
|
||||
|
||||
from pif_compiler.services.srv_echa import extract_levels, at_extractor, rdt_extractor, orchestrator
|
||||
from pif_compiler.functions.db_utils import postgres_connect
|
||||
from pif_compiler.functions.db_utils import postgres_connect, upsert_ingrediente, get_ingrediente_by_cas
|
||||
from pif_compiler.services.srv_pubchem import pubchem_dap
|
||||
from pif_compiler.services.srv_cosing import cosing_entry
|
||||
|
||||
|
|
@ -160,8 +174,9 @@ class CosingInfo(BaseModel):
|
|||
def cycle_identified(cls, cosing_data : dict):
|
||||
cosing_entries = []
|
||||
if 'identifiedIngredient' in cosing_data.keys():
|
||||
identified_cosing = cls.cosing_builder(cosing_data['identifiedIngredient'])
|
||||
cosing_entries.append(identified_cosing)
|
||||
for each_entry in cosing_data['identifiedIngredient']:
|
||||
identified_cosing = cls.cosing_builder(each_entry)
|
||||
cosing_entries.append(identified_cosing)
|
||||
main = cls.cosing_builder(cosing_data)
|
||||
cosing_entries.append(main)
|
||||
|
||||
|
|
@ -246,16 +261,17 @@ class Ingredient(BaseModel):
|
|||
creation_date: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def ingredient_builder(
|
||||
cls,
|
||||
cas: str,
|
||||
inci: Optional[List[str]] = None,
|
||||
dap_data: Optional[dict] = None,
|
||||
cosing_data: Optional[dict] = None,
|
||||
toxicity_data: Optional[dict] = None):
|
||||
def ingredient_builder(cls, cas: str, inci: Optional[List[str]] = None):
|
||||
# Recupera dati DAP da PubChem
|
||||
dap_data = pubchem_dap(cas)
|
||||
dap_info = DapInfo.dap_builder(dap_data) if isinstance(dap_data, dict) else None
|
||||
|
||||
dap_info = DapInfo.dap_builder(dap_data) if dap_data else None
|
||||
# Recupera dati COSING
|
||||
cosing_data = cosing_entry(cas)
|
||||
cosing_info = CosingInfo.cycle_identified(cosing_data) if cosing_data else None
|
||||
|
||||
# Recupera dati tossicologici da ECHA
|
||||
toxicity_data = orchestrator(cas)
|
||||
toxicity = Toxicity.from_result(cas, toxicity_data) if toxicity_data else None
|
||||
|
||||
return cls(
|
||||
|
|
@ -268,7 +284,8 @@ class Ingredient(BaseModel):
|
|||
|
||||
@model_validator(mode='after')
|
||||
def set_creation_date(self) -> 'Ingredient':
|
||||
self.creation_date = dt.now().isoformat()
|
||||
if self.creation_date is None:
|
||||
self.creation_date = dt.now().isoformat()
|
||||
return self
|
||||
|
||||
def update_ingredient(self, attr : str, data : dict):
|
||||
|
|
@ -278,6 +295,73 @@ class Ingredient(BaseModel):
|
|||
mongo_dict = self.model_dump()
|
||||
return mongo_dict
|
||||
|
||||
def save(self):
|
||||
"""Salva l'ingrediente su MongoDB (collection 'ingredients') e crea/aggiorna la riga in PostgreSQL."""
|
||||
from pif_compiler.functions.db_utils import db_connect
|
||||
|
||||
collection = db_connect(collection_name='ingredients')
|
||||
mongo_dict = self.to_mongo_dict()
|
||||
|
||||
# Upsert su MongoDB usando il CAS come chiave
|
||||
result = collection.replace_one(
|
||||
{"cas": self.cas},
|
||||
mongo_dict,
|
||||
upsert=True
|
||||
)
|
||||
|
||||
# Recupera l'ObjectId del documento (inserito o esistente)
|
||||
if result.upserted_id:
|
||||
mongo_id = str(result.upserted_id)
|
||||
else:
|
||||
doc = collection.find_one({"cas": self.cas}, {"_id": 1})
|
||||
mongo_id = str(doc["_id"])
|
||||
|
||||
# Segna i flag di arricchimento
|
||||
has_dap = self.dap_info is not None
|
||||
has_cosing = self.cosing_info is not None
|
||||
has_tox = self.toxicity is not None
|
||||
|
||||
# Upsert su PostgreSQL
|
||||
upsert_ingrediente(self.cas, mongo_id, dap=has_dap, cosing=has_cosing, tox=has_tox)
|
||||
|
||||
return mongo_id
|
||||
|
||||
@classmethod
|
||||
def from_cas(cls, cas: str):
|
||||
"""Recupera un ingrediente da MongoDB tramite il CAS, usando PostgreSQL come indice."""
|
||||
from pif_compiler.functions.db_utils import db_connect
|
||||
from bson import ObjectId
|
||||
|
||||
# Cerca in PostgreSQL per ottenere il mongo_id
|
||||
pg_entry = get_ingrediente_by_cas(cas)
|
||||
if not pg_entry:
|
||||
return None
|
||||
|
||||
_, _, mongo_id, _, _, _ = pg_entry
|
||||
if not mongo_id:
|
||||
return None
|
||||
|
||||
# Recupera il documento da MongoDB
|
||||
collection = db_connect(collection_name='ingredients')
|
||||
doc = collection.find_one({"_id": ObjectId(mongo_id)})
|
||||
if not doc:
|
||||
return None
|
||||
|
||||
doc.pop("_id", None)
|
||||
return cls(**doc)
|
||||
|
||||
@classmethod
|
||||
def get_or_create(cls, cas: str, inci: Optional[List[str]] = None):
|
||||
"""Restituisce l'ingrediente dalla cache se esiste e non è vecchio, altrimenti lo ricrea."""
|
||||
cached = cls.from_cas(cas)
|
||||
if cached and not cached.is_old():
|
||||
return cached
|
||||
|
||||
# Crea un nuovo ingrediente (scraping) e lo salva
|
||||
ingredient = cls.ingredient_builder(cas, inci=inci)
|
||||
ingredient.save()
|
||||
return ingredient
|
||||
|
||||
def get_stats(self):
|
||||
stats = {
|
||||
"has_dap_info": self.dap_info is not None,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,107 @@ def insert_compilatore(nome_compilatore):
|
|||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
def aggiorna_stato_ordine(id_ordine, nuovo_stato):
|
||||
try:
|
||||
conn = postgres_connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE ordini SET stato_ordine = %s WHERE id_ordine = %s",
|
||||
(int(nuovo_stato), id_ordine)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating stato ordine {id_ordine}: {e}")
|
||||
|
||||
def upsert_ingrediente(cas, mongo_id, dap=False, cosing=False, tox=False):
|
||||
"""Inserisce o aggiorna un ingrediente nella tabella ingredienti di PostgreSQL."""
|
||||
try:
|
||||
conn = postgres_connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
INSERT INTO ingredienti (cas, mongo_id, dap, cosing, tox)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (cas) DO UPDATE SET
|
||||
mongo_id = EXCLUDED.mongo_id,
|
||||
dap = EXCLUDED.dap,
|
||||
cosing = EXCLUDED.cosing,
|
||||
tox = EXCLUDED.tox
|
||||
RETURNING id;
|
||||
""", (cas, mongo_id, dap, cosing, tox))
|
||||
result = cur.fetchone()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"Errore upsert ingrediente {cas}: {e}")
|
||||
return None
|
||||
|
||||
def get_ingrediente_by_cas(cas):
|
||||
"""Recupera un ingrediente dalla tabella ingredienti di PostgreSQL tramite CAS."""
|
||||
try:
|
||||
conn = postgres_connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, cas, mongo_id, dap, cosing, tox FROM ingredienti WHERE cas = %s",
|
||||
(cas,)
|
||||
)
|
||||
result = cur.fetchone()
|
||||
conn.close()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Errore recupero ingrediente {cas}: {e}")
|
||||
return None
|
||||
|
||||
def get_all_ingredienti():
|
||||
"""Recupera tutti gli ingredienti dalla tabella ingredienti di PostgreSQL."""
|
||||
try:
|
||||
conn = postgres_connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id, cas, mongo_id, dap, cosing, tox, created_at FROM ingredienti ORDER BY created_at DESC")
|
||||
results = cur.fetchall()
|
||||
conn.close()
|
||||
return results if results else []
|
||||
except Exception as e:
|
||||
logger.error(f"Errore recupero ingredienti: {e}")
|
||||
return []
|
||||
|
||||
def upsert_cliente(nome_cliente):
|
||||
"""Inserisce o recupera un cliente. Ritorna id_cliente."""
|
||||
try:
|
||||
conn = postgres_connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO clienti (nome_cliente) VALUES (%s) ON CONFLICT (nome_cliente) DO NOTHING",
|
||||
(nome_cliente,)
|
||||
)
|
||||
conn.commit()
|
||||
cur.execute("SELECT id_cliente FROM clienti WHERE nome_cliente = %s", (nome_cliente,))
|
||||
result = cur.fetchone()
|
||||
conn.close()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"Errore upsert cliente {nome_cliente}: {e}")
|
||||
return None
|
||||
|
||||
def upsert_compilatore(nome_compilatore):
|
||||
"""Inserisce o recupera un compilatore. Ritorna id_compilatore."""
|
||||
try:
|
||||
conn = postgres_connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO compilatori (nome_compilatore) VALUES (%s) ON CONFLICT (nome_compilatore) DO NOTHING",
|
||||
(nome_compilatore,)
|
||||
)
|
||||
conn.commit()
|
||||
cur.execute("SELECT id_compilatore FROM compilatori WHERE nome_compilatore = %s", (nome_compilatore,))
|
||||
result = cur.fetchone()
|
||||
conn.close()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"Errore upsert compilatore {nome_compilatore}: {e}")
|
||||
return None
|
||||
|
||||
def log_ricerche(cas, target, esito):
|
||||
try:
|
||||
conn = postgres_connect()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import time
|
|||
from pif_compiler.functions.common_log import get_logger
|
||||
|
||||
# Import dei tuoi router
|
||||
from pif_compiler.api.routes import api_echa, api_cosing, common
|
||||
from pif_compiler.api.routes import api_echa, api_cosing, common, api_ingredients, api_esposition
|
||||
|
||||
# Configurazione logging
|
||||
logger = get_logger()
|
||||
|
|
@ -135,6 +135,18 @@ app.include_router(
|
|||
tags=["Common"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
api_ingredients.router,
|
||||
prefix="/api/v1",
|
||||
tags=["Ingredients"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
api_esposition.router,
|
||||
prefix="/api/v1",
|
||||
tags=["Esposition"]
|
||||
)
|
||||
|
||||
# ==================== ROOT ENDPOINTS ====================
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
|
|
|
|||
Loading…
Reference in a new issue