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
|
# other
|
||||||
|
|
||||||
pdfs/
|
pdfs/
|
||||||
|
streamlit/
|
||||||
61
CLAUDE.md
61
CLAUDE.md
|
|
@ -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
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
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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 (
|
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",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
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
|
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,7 +174,8 @@ 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']:
|
||||||
|
identified_cosing = cls.cosing_builder(each_entry)
|
||||||
cosing_entries.append(identified_cosing)
|
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,16 +261,17 @@ 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,
|
|
||||||
toxicity_data: Optional[dict] = 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
|
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(
|
||||||
|
|
@ -268,6 +284,7 @@ class Ingredient(BaseModel):
|
||||||
|
|
||||||
@model_validator(mode='after')
|
@model_validator(mode='after')
|
||||||
def set_creation_date(self) -> 'Ingredient':
|
def set_creation_date(self) -> 'Ingredient':
|
||||||
|
if self.creation_date is None:
|
||||||
self.creation_date = dt.now().isoformat()
|
self.creation_date = dt.now().isoformat()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -278,6 +295,73 @@ class Ingredient(BaseModel):
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue