update: new class

This commit is contained in:
adish-rmr 2026-02-08 19:29:22 +01:00
parent e02aca560c
commit 0612667b22
13 changed files with 836 additions and 87 deletions

1
.gitignore vendored
View file

@ -209,3 +209,4 @@ __marimo__/
# other
pdfs/
streamlit/

View file

@ -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
View 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
View 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');

View file

@ -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

View file

@ -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

View 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)}"
)

View 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)}"
)

View file

@ -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",
]

View 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

View file

@ -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,

View file

@ -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()

View file

@ -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"])