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

3
.gitignore vendored
View file

@ -208,4 +208,5 @@ __marimo__/
# other # other
pdfs/ pdfs/
streamlit/

View file

@ -26,10 +26,15 @@ src/pif_compiler/
│ └── routes/ │ └── routes/
│ ├── api_echa.py # ECHA endpoints (single + batch search) │ ├── api_echa.py # ECHA endpoints (single + batch search)
│ ├── api_cosing.py # COSING 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 │ └── common.py # PDF generation, PubChem, CIR search endpoints
├── classes/ ├── classes/
│ └── models.py # Pydantic models: Ingredient, DapInfo, CosingInfo, │ ├── __init__.py # Re-exports all models from models.py and main_cls.py
│ # ToxIndicator, Toxicity, Esposition, RetentionFactors │ ├── 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/ ├── functions/
│ ├── common_func.py # PDF generation with Playwright │ ├── common_func.py # PDF generation with Playwright
│ ├── common_log.py # Centralized logging configuration │ ├── common_log.py # Centralized logging configuration
@ -44,9 +49,10 @@ src/pif_compiler/
### Other directories ### 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 - `logs/` - Rotating log files (debug.log, error.log) - auto-generated
- `pdfs/` - Generated PDF files from ECHA dossier pages - `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 - `marimo/` - **Ignore this folder.** Debug/test notebooks, not part of the main application
## Architecture & Data Flow ## 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 6. **Toxicity ranking** (`Toxicity` model): Best toxicological indicator selection with priority (NOAEL > LOAEL > LD50) and safety factors
### Caching strategy ### 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 - 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) - 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 ## API Endpoints
All routes are under `/api/v1`: 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 | `/echa/batch-search` | Batch ECHA search for multiple CAS numbers |
| POST | `/cosing/search` | COSING search (by name, CAS, EC, or ID) | | POST | `/cosing/search` | COSING search (by name, CAS, EC, or ID) |
| POST | `/cosing/batch-search` | Batch COSING search | | 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/pubchem` | PubChem property lookup by CAS |
| POST | `/common/generate-pdf` | Generate PDF from URL via Playwright | | POST | `/common/generate-pdf` | Generate PDF from URL via Playwright |
| GET | `/common/download-pdf/{name}` | Download a generated PDF | | 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 ### Key conventions
- Services in `services/` handle external API calls and data extraction - 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 - 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 - 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()` - All modules use the shared logger from `common_log.get_logger()`
- API routes define Pydantic request/response models inline in each route file - 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 ### Important domain concepts
- **CAS number**: Chemical Abstracts Service identifier (e.g., "50-00-0") - **CAS number**: Chemical Abstracts Service identifier (e.g., "50-00-0")
- **INCI**: International Nomenclature of Cosmetic Ingredients - **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 @app.cell
def _(): def _():
from pif_compiler.services.srv_cosing import cosing_search from pif_compiler.services.srv_cosing import cosing_entry
return (cosing_search,) from pif_compiler.classes.models import CosingInfo
return CosingInfo, cosing_entry
@app.cell @app.cell
def _(): def _():
cas = ' 9006-65-9 ' cas = '64-17-5'
return (cas,) return (cas,)
@app.cell @app.cell
def _(cas, cosing_search): def _(cas, cosing_entry):
cosing_search(cas, mode='cas') 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 return

View file

@ -6,42 +6,19 @@ app = marimo.App(width="medium")
@app.cell @app.cell
def _(): def _():
from pif_compiler.classes.models import Esposition from pif_compiler.classes.models import Ingredient
return (Esposition,)
# 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 # Salva su MongoDB + PostgreSQL
def _(Esposition): mongo_id = ing.save()
it = Esposition( print(f"Salvato con mongo_id: {mongo_id}")
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
return 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 ( from pif_compiler.classes.models import (
StatoOrdine,
DapInfo,
CosingInfo,
ToxIndicator,
Toxicity,
Ingredient, Ingredient,
ExpositionInfo, RetentionFactors,
SedTable, Esposition,
ProdCompany,
) )
from pif_compiler.classes.pif_class import PIF from pif_compiler.classes.main_cls import (
IngredientInput,
from pif_compiler.classes.types_enum import ( Order,
CosmeticType, Project,
PhysicalForm,
PlaceApplication,
NormalUser,
RoutesExposure,
NanoRoutes,
TranslatedEnum,
) )
__all__ = [ __all__ = [
# Main PIF model "StatoOrdine",
"PIF", "DapInfo",
# Component models "CosingInfo",
"ToxIndicator",
"Toxicity",
"Ingredient", "Ingredient",
"ExpositionInfo", "RetentionFactors",
"SedTable", "Esposition",
"ProdCompany", "IngredientInput",
# Enums "Order",
"CosmeticType", "Project",
"PhysicalForm",
"PlaceApplication",
"NormalUser",
"RoutesExposure",
"NanoRoutes",
"TranslatedEnum",
] ]

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 from pydantic import BaseModel, Field, field_validator, ConfigDict, model_validator, computed_field
import re import re
from enum import IntEnum
from typing import List, Optional from typing import List, Optional
from datetime import datetime as dt 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.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_pubchem import pubchem_dap
from pif_compiler.services.srv_cosing import cosing_entry from pif_compiler.services.srv_cosing import cosing_entry
@ -160,8 +174,9 @@ class CosingInfo(BaseModel):
def cycle_identified(cls, cosing_data : dict): def cycle_identified(cls, cosing_data : dict):
cosing_entries = [] cosing_entries = []
if 'identifiedIngredient' in cosing_data.keys(): if 'identifiedIngredient' in cosing_data.keys():
identified_cosing = cls.cosing_builder(cosing_data['identifiedIngredient']) for each_entry in cosing_data['identifiedIngredient']:
cosing_entries.append(identified_cosing) identified_cosing = cls.cosing_builder(each_entry)
cosing_entries.append(identified_cosing)
main = cls.cosing_builder(cosing_data) main = cls.cosing_builder(cosing_data)
cosing_entries.append(main) cosing_entries.append(main)
@ -246,18 +261,19 @@ class Ingredient(BaseModel):
creation_date: Optional[str] = None creation_date: Optional[str] = None
@classmethod @classmethod
def ingredient_builder( def ingredient_builder(cls, cas: str, inci: Optional[List[str]] = None):
cls, # Recupera dati DAP da PubChem
cas: str, dap_data = pubchem_dap(cas)
inci: Optional[List[str]] = None, dap_info = DapInfo.dap_builder(dap_data) if isinstance(dap_data, dict) else None
dap_data: Optional[dict] = None,
cosing_data: Optional[dict] = None, # Recupera dati COSING
toxicity_data: Optional[dict] = None): cosing_data = cosing_entry(cas)
dap_info = DapInfo.dap_builder(dap_data) if dap_data else None
cosing_info = CosingInfo.cycle_identified(cosing_data) if cosing_data else None 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 toxicity = Toxicity.from_result(cas, toxicity_data) if toxicity_data else None
return cls( return cls(
cas=cas, cas=cas,
inci=inci, inci=inci,
@ -268,7 +284,8 @@ class Ingredient(BaseModel):
@model_validator(mode='after') @model_validator(mode='after')
def set_creation_date(self) -> 'Ingredient': 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 return self
def update_ingredient(self, attr : str, data : dict): def update_ingredient(self, attr : str, data : dict):
@ -277,7 +294,74 @@ class Ingredient(BaseModel):
def to_mongo_dict(self): def to_mongo_dict(self):
mongo_dict = self.model_dump() mongo_dict = self.model_dump()
return mongo_dict 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): def get_stats(self):
stats = { stats = {
"has_dap_info": self.dap_info is not None, "has_dap_info": self.dap_info is not None,

View file

@ -56,6 +56,107 @@ def insert_compilatore(nome_compilatore):
except Exception as e: except Exception as e:
logger.error(f"Error: {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): def log_ricerche(cas, target, esito):
try: try:
conn = postgres_connect() conn = postgres_connect()

View file

@ -8,7 +8,7 @@ import time
from pif_compiler.functions.common_log import get_logger from pif_compiler.functions.common_log import get_logger
# Import dei tuoi router # 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 # Configurazione logging
logger = get_logger() logger = get_logger()
@ -135,6 +135,18 @@ app.include_router(
tags=["Common"] 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 ==================== # ==================== ROOT ENDPOINTS ====================
@app.get("/", tags=["Root"]) @app.get("/", tags=["Root"])