Compare commits

..

No commits in common. "7ef78b16900fb76ec848f31519d3dc85bc417755" and "448060155abfb1083975cf3c74059d15b2ec6b55" have entirely different histories.

20 changed files with 420 additions and 29425 deletions

View file

@ -29,7 +29,7 @@ src/pif_compiler/
│ ├── api_ingredients.py # Ingredient search by CAS + list all ingested + add tox indicator + clients CRUD
│ ├── api_esposition.py # Esposition preset CRUD (create, list, delete)
│ ├── api_orders.py # Order creation, retry, manual pipeline trigger, Excel/PDF export
│ └── common.py # PDF generation, PubChem, CIR search, segnalazione endpoints
│ └── common.py # PDF generation, PubChem, CIR search endpoints
├── classes/
│ ├── __init__.py # Re-exports all models from models.py and main_workflow.py
│ ├── models.py # Pydantic models: Ingredient, DapInfo, CosingInfo,
@ -39,7 +39,6 @@ src/pif_compiler/
│ # orchestrator functions (receive_order, process_order_pipeline,
│ # retry_order, trigger_pipeline)
├── functions/
│ ├── auth.py # JWT verification via Supabase JWKS (RS256/ES256), get_current_user FastAPI dependency
│ ├── common_func.py # PDF generation with Playwright, tox+COSING source PDF batch generation, COSING PDF download, ZIP creation
│ ├── common_log.py # Centralized logging configuration
│ ├── db_utils.py # MongoDB + PostgreSQL connection helpers
@ -69,7 +68,7 @@ src/pif_compiler/
3. **ECHA** (`srv_echa.py`): Search substance -> get dossier -> parse HTML index -> extract toxicological data (NOAEL, LD50, LOAEL) from acute & repeated dose toxicity pages
4. **PubChem** (`srv_pubchem.py`): Get molecular weight, XLogP, TPSA, melting point, dissociation constants
5. **DAP calculation** (`DapInfo` model): Dermal Absorption Percentage based on molecular properties (MW > 500, LogP, TPSA > 120, etc.)
6. **Toxicity ranking** (`Toxicity` model): Best toxicological indicator selection with 3-tier priority: (1) indicator type NOAEL=4 > LOAEL=3 > LD50=1; (2) route preference dermal > oral > inhalation > other; (3) lowest value for NOAEL/LOAEL (most conservative). Safety factors: LD50→10, LOAEL→3, NOAEL→1.
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`
@ -109,7 +108,7 @@ POST /orders/create → receive_order() → BackgroundTasks → process_order_pi
On error → stato=9 (ERRORE) + note with error message
```
- **Order** (`main_workflow.py`): Pydantic model with DB table attributes + raw JSON from MongoDB. `pick_next()` classmethod picks the oldest pending order (FIFO). `validate_anagrafica()` upserts client in `clienti` table. `update_stato()` is the reusable state transition method. The compiler (`id_compilatore`) is resolved at order creation time via `receive_order(raw_json, compiler_name)` from the authenticated user's JWT — not during the pipeline.
- **Order** (`main_workflow.py`): Pydantic model with DB table attributes + raw JSON from MongoDB. `pick_next()` classmethod picks the oldest pending order (FIFO). `validate_anagrafica()` upserts client in `clienti` table. `update_stato()` is the reusable state transition method.
- **Project** (`main_workflow.py`): Created from Order via `Project.from_order()`. Holds `Esposition` preset (loaded by name from DB), list of `ProjectIngredient` with enriched `Ingredient` objects. `process_ingredients()` calls `Ingredient.get_or_create()` for each CAS. `save()` dumps to MongoDB `projects` collection, creates `progetti` entry, and populates `ingredients_lineage`.
- **ProjectIngredient**: Helper model with cas, inci, percentage, is_colorante, skip_tox, and optional `Ingredient` object.
- **Retry**: `retry_order(id_ordine)` resets an ERRORE order back to RICEVUTO for reprocessing.
@ -131,9 +130,9 @@ Called via `Project.export_excel()` method, exposed at `GET /orders/export/{id_o
- `generate_project_source_pdfs(project)` — for each ingredient, generates two types of source PDFs:
1. **Tox best_case PDF**: downloads the ECHA dossier page of `best_case` via Playwright. Naming: `CAS_source.pdf` (source is the `ToxIndicator.source` attribute, e.g., `56-81-5_repeated_dose_toxicity.pdf`)
2. **COSING PDF**: one PDF per ingredient, using the first `CosingInfo` entry with a valid `reference`. Naming: `CAS_cosing.pdf`. Note: an ingredient may have multiple `CosingInfo` entries but only the first valid reference is used.
2. **COSING PDF**: downloads the official COSING regulation PDF via EU API for each `CosingInfo` with a `reference` attribute. Naming: `CAS_cosing.pdf`
- `cosing_download(ref_no)` — downloads the COSING regulation PDF from `api.tech.ec.europa.eu` by reference number. Returns PDF bytes or error string
- `create_sources_zip(pdf_paths, zip_path)` — bundles all source PDFs into a ZIP archive; deduplicates by filename to prevent duplicate entries
- `create_sources_zip(pdf_paths, zip_path)` — bundles all source PDFs into a ZIP archive
- Exposed at `GET /orders/export-sources/{id_ordine}` — returns ZIP as FileResponse
### PostgreSQL schema (see `data/db_schema.sql`)
@ -175,15 +174,10 @@ All routes are under `/api/v1`:
| GET | `/orders/list` | List all orders with client/compiler/status info |
| GET | `/orders/detail/{id_ordine}` | Full order detail with ingredients from MongoDB |
| DELETE | `/orders/{id_ordine}` | Delete order and all related data (PostgreSQL + MongoDB) |
| POST | `/auth/login` | Login with email+password, returns access+refresh token (public) |
| POST | `/auth/refresh` | Refresh access token via refresh token (public) |
| POST | `/auth/logout` | Invalidate session on Supabase (public) |
| GET | `/auth/me` | Returns current user info from JWT (id, email, name) |
| 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 |
| POST | `/common/cir-search` | CIR ingredient text search |
| POST | `/common/segnalazione` | Save a user bug report/ticket to MongoDB `segnalazioni` collection |
| GET | `/health`, `/ping` | Health check endpoints |
Docs available at `/docs` (Swagger) and `/redoc`.
@ -197,8 +191,6 @@ Configured via `.env` file (loaded with `python-dotenv`):
- `MONGO_HOST` - MongoDB host
- `MONGO_PORT` - MongoDB port
- `DATABASE_URL` - PostgreSQL connection string
- `SUPABASE_URL` - Supabase project URL (e.g. `https://xxx.supabase.co`)
- `SUPABASE_SECRET_KEY` - Supabase service role key (used for auth proxying)
## Development
@ -243,16 +235,13 @@ uv run uvicorn pif_compiler.main:app --reload --host 0.0.0.0 --port 8000
- `upsert_cliente(nome_cliente)` - Upsert client, returns `id_cliente`
- `upsert_compilatore(nome_compilatore)` - Upsert compiler, returns `id_compilatore`
- `get_all_clienti()` - List all clients from PostgreSQL
- `get_all_compilatori()` - List all compilers from PostgreSQL
- `delete_cliente(nome_cliente)` - Delete client if no linked orders, returns None if blocked
**Orders:**
- `insert_ordine(uuid_ordine, id_cliente=None, id_compilatore=None)` - Insert new order, returns `id_ordine`
- `insert_ordine(uuid_ordine, id_cliente)` - Insert new order, returns `id_ordine`
- `get_ordine_by_id(id_ordine)` - Get full order row
- `get_oldest_pending_order()` - Get oldest order with stato=RICEVUTO
- `aggiorna_stato_ordine(id_ordine, nuovo_stato)` - Update order status
- `update_ordine_cliente(id_ordine, id_cliente)` - Set client on order
- `update_ordine_compilatore(id_ordine, id_compilatore)` - Set compiler on order
- `update_ordine_progetto(id_ordine, uuid_progetto)` - Set project UUID on order
- `update_ordine_note(id_ordine, note)` - Set note on order
- `reset_ordine_per_retry(id_ordine)` - Reset ERRORE order to RICEVUTO
@ -268,43 +257,12 @@ uv run uvicorn pif_compiler.main:app --reload --host 0.0.0.0 --port 8000
- `log_ricerche(cas, target, esito)` - Log search history
### Streamlit UI
Entry point: `streamlit/app.py` (multi-page app via `st.navigation`). Run with:
```bash
streamlit run streamlit/app.py
```
API must be running on `localhost:8000`.
#### Shared modules
- `streamlit/functions.py` — single source of truth for all shared logic:
- Constants: `API_BASE`, `AUTH_BASE`, `CAS_PATTERN`, `STATUS_MAP`, `WATER_INCI`
- Auth: `do_login`, `do_refresh`, `do_logout`, `check_auth`, `_auth_headers`, `_fetch_user_info`
- Cookie persistence: `get_cookie_manager()`, `_COOKIE_RT="pif_rt"`, `_COOKIE_MAX_AGE=7d` — uses `extra-streamlit-components` CookieManager. Only the `refresh_token` is stored in the browser cookie. `check_auth` restores session from cookie automatically on new browser sessions.
- All API wrappers: `fetch_ingredient`, `fetch_orders`, `fetch_order_detail`, `download_excel`, `download_sources`, `send_segnalazione`, etc.
- Order helpers: `validate_order`, `build_order_payload`, `make_empty_ingredient_df`, `is_water_inci`
- ECHA extractors: `extract_tox_info_values`, `extract_acute_values`, `extract_repeated_values`
- `streamlit/functions_ui.py` — UI-level helpers:
- `search_cas_inci(input, type)` — DuckDB query on `streamlit/data.csv`
- `search_cir(input_text)` — DuckDB query on `streamlit/cir-reports.csv`, returns `list[tuple[name, inci, url]]`
- `show_login_page()` — login form calling `do_login`
- `display_orderData(order_data)` — renders order detail
#### Pages (`streamlit/pages/`)
- `ingredients_page.py` — ingredient search by CAS, displays DAP/COSING/tox data, PDF source download
- `order_page.py` — order creation form: client, preset, ingredient table (INCI/CAS/%, AQUA auto-detection), submit → POST `/orders/create`
- `list_orders.py` — order list with filters; detail view; retry/download/delete actions
- `exposition_page.py` — exposure preset CRUD
- `settings_page.py` — custom tox indicators, client management, ingredient inventory
- `echa.py` — legacy ECHA direct search
- `ticket.py` — bug report form → POST `/common/segnalazione`
#### Auth flow
1. `get_cookie_manager()` is called at the very top of `app.py` before `check_auth` to render the JS component
2. On login: tokens saved to `session_state` + `refresh_token` saved to cookie `pif_rt`
3. On new session (tab reopen): `check_auth` reads cookie → calls `do_refresh` → restores session automatically
4. On logout: `session_state` cleared + cookie deleted
5. `_fetch_user_info()` called after login/restore → saves `user_name`, `user_email`, `user_id` to session_state
6. Selected CAS/INCI saved to `session_state.selected_cas` / `session_state.selected_inci` for cross-page navigation
- `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
- `streamlit/order_page.py` - Order creation form (client dropdown, preset selection, ingredient data_editor with CAS/INCI/percentage, AQUA auto-detection, validation, submit with background processing)
- `streamlit/orders_page.py` - Order management: list with filters (date, client, status), detail view with ingredients, actions (refresh, retry, Excel download, PDF sources ZIP, delete with confirmation), notes/log display
- All 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")

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -19,21 +19,17 @@ dependencies = [
"psycopg2-binary>=2.9.11",
"pubchemprops>=0.1.1",
"pubchempy>=1.0.5",
"pydantic[email]>=2.11.10",
"pydantic>=2.11.10",
"pymongo>=4.15.2",
"pytest>=8.4.2",
"pytest-cov>=7.0.0",
"pytest-mock>=3.15.1",
"python-dotenv>=1.2.1",
"httpx>=0.27.0",
"PyJWT>=2.9.0",
"requests>=2.32.5",
"streamlit>=1.50.0",
"extra-streamlit-components>=0.1.71",
"openpyxl>=3.1.0",
"uvicorn>=0.35.0",
"weasyprint>=66.0",
"cryptography>=46.0.5",
]
[project.scripts]

View file

@ -0,0 +1,246 @@
"""
Script per creare un ordine mock con 4 ingredienti per testare la UI.
Inserisce direttamente nei database senza passare dalla pipeline (no scraping).
Uso: uv run python scripts/create_mock_order.py
"""
import sys
import os
# Aggiungi il path del progetto
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from pif_compiler.functions.db_utils import (
db_connect, upsert_cliente, insert_ordine, aggiorna_stato_ordine,
update_ordine_cliente, upsert_ingrediente
)
from pif_compiler.classes.models import (
StatoOrdine, Ingredient, DapInfo, CosingInfo, ToxIndicator, Toxicity, Esposition
)
from pif_compiler.classes.main_workflow import Project, ProjectIngredient
def ensure_preset_exists(preset_name="Test Preset"):
"""Verifica che il preset esista, altrimenti lo crea."""
preset = Esposition.get_by_name(preset_name)
if preset:
print(f"Preset '{preset_name}' già esistente")
return preset
print(f"Creazione preset '{preset_name}'...")
preset = Esposition(
preset_name=preset_name,
tipo_prodotto="Crema corpo",
luogo_applicazione="Corpo",
esp_normali=["Dermal"],
esp_secondarie=["Oral"],
esp_nano=[],
sup_esposta=15670,
freq_applicazione=1,
qta_giornaliera=7.82,
ritenzione=1.0
)
result = preset.save_to_postgres()
if result:
print(f"Preset creato con id_preset={result}")
else:
print("ERRORE: impossibile creare il preset")
sys.exit(1)
return preset
def create_mock_ingredients():
"""Crea ingredienti mock con dati finti di tossicologia e DAP."""
# GLYCERIN (56-81-5) — con NOAEL
glycerin = Ingredient(
cas="56-81-5",
inci=["GLYCERIN"],
dap_info=DapInfo(
cas="56-81-5",
molecular_weight=92.09,
log_pow=-1.76,
tpsa=60.69,
melting_point=18.0
),
cosing_info=[CosingInfo(
cas=["56-81-5"],
common_names=["Glycerol"],
inci=["GLYCERIN"],
annex=[],
functionName=["Humectant", "Solvent", "Skin conditioning"],
otherRestrictions=[],
cosmeticRestriction=None
)],
toxicity=Toxicity(
cas="56-81-5",
indicators=[
ToxIndicator(
indicator="NOAEL", value=1000, unit="mg/kg bw/day",
route="oral", toxicity_type="repeated_dose_toxicity",
ref="https://chem.echa.europa.eu/100.003.264"
),
ToxIndicator(
indicator="LD50", value=12600, unit="mg/kg bw",
route="oral", toxicity_type="acute_toxicity",
ref="https://chem.echa.europa.eu/100.003.264"
)
]
)
)
# CETYL ALCOHOL (36653-82-4) — con NOAEL
cetyl = Ingredient(
cas="36653-82-4",
inci=["CETYL ALCOHOL"],
dap_info=DapInfo(
cas="36653-82-4",
molecular_weight=242.44,
log_pow=6.83,
tpsa=20.23,
melting_point=49.0
),
cosing_info=[CosingInfo(
cas=["36653-82-4"],
common_names=["Cetyl alcohol", "1-Hexadecanol"],
inci=["CETYL ALCOHOL"],
annex=[],
functionName=["Emollient", "Emulsifying", "Opacifying"],
otherRestrictions=[],
cosmeticRestriction=None
)],
toxicity=Toxicity(
cas="36653-82-4",
indicators=[
ToxIndicator(
indicator="NOAEL", value=1000, unit="mg/kg bw/day",
route="oral", toxicity_type="repeated_dose_toxicity",
ref="https://chem.echa.europa.eu/100.004.098"
)
]
)
)
# TOCOPHEROL (59-02-9) — con LOAEL
tocopherol = Ingredient(
cas="59-02-9",
inci=["TOCOPHEROL"],
dap_info=DapInfo(
cas="59-02-9",
molecular_weight=430.71,
log_pow=10.51,
tpsa=29.46,
melting_point=3.0
),
cosing_info=[CosingInfo(
cas=["59-02-9"],
common_names=["alpha-Tocopherol"],
inci=["TOCOPHEROL"],
annex=[],
functionName=["Antioxidant", "Skin conditioning"],
otherRestrictions=[],
cosmeticRestriction=None
)],
toxicity=Toxicity(
cas="59-02-9",
indicators=[
ToxIndicator(
indicator="LOAEL", value=500, unit="mg/kg bw/day",
route="oral", toxicity_type="repeated_dose_toxicity",
ref="https://chem.echa.europa.eu/100.000.375"
)
]
)
)
# Salva ogni ingrediente su MongoDB + PostgreSQL
for ing in [glycerin, cetyl, tocopherol]:
mongo_id = ing.save()
print(f"Ingrediente {ing.cas} ({ing.inci[0]}) salvato (mongo_id={mongo_id})")
return glycerin, cetyl, tocopherol
def create_mock_order(preset, glycerin, cetyl, tocopherol):
"""Crea un ordine mock completo."""
# 1. Upsert cliente
client_name = "Cosmetica Test Srl"
id_cliente = upsert_cliente(client_name)
print(f"Cliente '{client_name}' → id_cliente={id_cliente}")
# 2. JSON ordine grezzo
raw_json = {
"client_name": client_name,
"product_name": "Crema Idratante Test",
"preset_esposizione": preset.preset_name,
"ingredients": [
{"inci": "AQUA", "cas": "", "percentage": 70.0, "is_colorante": False, "skip_tox": True},
{"inci": "GLYCERIN", "cas": "56-81-5", "percentage": 15.0, "is_colorante": False, "skip_tox": False},
{"inci": "CETYL ALCOHOL", "cas": "36653-82-4", "percentage": 10.0, "is_colorante": False, "skip_tox": False},
{"inci": "TOCOPHEROL", "cas": "59-02-9", "percentage": 5.0, "is_colorante": False, "skip_tox": False},
]
}
# 3. Salva su MongoDB orders
orders_col = db_connect(collection_name='orders')
result = orders_col.insert_one(raw_json.copy())
uuid_ordine = str(result.inserted_id)
print(f"Ordine salvato su MongoDB: uuid_ordine={uuid_ordine}")
# 4. Inserisci in PostgreSQL ordini
id_ordine = insert_ordine(uuid_ordine, id_cliente)
print(f"Ordine inserito in PostgreSQL: id_ordine={id_ordine}")
# 5. Aggiorna stato a ARRICCHITO
update_ordine_cliente(id_ordine, id_cliente)
aggiorna_stato_ordine(id_ordine, int(StatoOrdine.ARRICCHITO))
print(f"Stato ordine aggiornato a ARRICCHITO ({StatoOrdine.ARRICCHITO})")
# 6. Crea progetto con ingredienti arricchiti
project = Project(
order_id=id_ordine,
product_name="Crema Idratante Test",
client_name=client_name,
esposition=preset,
ingredients=[
ProjectIngredient(cas=None, inci="AQUA", percentage=70.0, skip_tox=True),
ProjectIngredient(cas="56-81-5", inci="GLYCERIN", percentage=15.0, ingredient=glycerin),
ProjectIngredient(cas="36653-82-4", inci="CETYL ALCOHOL", percentage=10.0, ingredient=cetyl),
ProjectIngredient(cas="59-02-9", inci="TOCOPHEROL", percentage=5.0, ingredient=tocopherol),
]
)
# 7. Salva il progetto (MongoDB + PostgreSQL)
uuid_progetto = project.save()
print(f"Progetto salvato: uuid_progetto={uuid_progetto}")
print("\n" + "=" * 60)
print("MOCK ORDER CREATO CON SUCCESSO")
print("=" * 60)
print(f" id_ordine: {id_ordine}")
print(f" uuid_ordine: {uuid_ordine}")
print(f" uuid_progetto: {uuid_progetto}")
print(f" cliente: {client_name}")
print(f" prodotto: Crema Idratante Test")
print(f" preset: {preset.preset_name}")
print(f" ingredienti: 4 (AQUA, GLYCERIN, CETYL ALCOHOL, TOCOPHEROL)")
print(f" stato: ARRICCHITO ({StatoOrdine.ARRICCHITO})")
print("=" * 60)
return id_ordine
if __name__ == "__main__":
print("Creazione ordine mock...")
print()
# 1. Assicura che il preset esista
preset = ensure_preset_exists()
# 2. Crea ingredienti mock
glycerin, cetyl, tocopherol = create_mock_ingredients()
# 3. Crea l'ordine
create_mock_order(preset, glycerin, cetyl, tocopherol)

View file

@ -1,114 +0,0 @@
import os
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from pif_compiler.functions.auth import get_current_user
from pif_compiler.functions.common_log import get_logger
logger = get_logger()
router = APIRouter()
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
SUPABASE_SECRET_KEY = os.getenv("SUPABASE_SECRET_KEY", "")
_SUPABASE_HEADERS = {
"apikey": SUPABASE_SECRET_KEY,
"Content-Type": "application/json",
}
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
def _supabase_auth_url(path: str) -> str:
return f"{SUPABASE_URL}/auth/v1/{path}"
@router.post("/auth/login", tags=["Auth"])
async def login(request: LoginRequest):
"""Autentica con email e password. Ritorna access_token e refresh_token."""
async with httpx.AsyncClient() as client:
resp = await client.post(
_supabase_auth_url("token?grant_type=password"),
headers=_SUPABASE_HEADERS,
json={"email": request.email, "password": request.password},
timeout=10,
)
if resp.status_code == 400:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenziali non valide"
)
if resp.status_code != 200:
logger.error(f"login: Supabase HTTP {resp.status_code}{resp.text}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Errore nel servizio di autenticazione"
)
data = resp.json()
logger.info(f"login: accesso di {request.email}")
return {
"access_token": data["access_token"],
"refresh_token": data["refresh_token"],
"expires_in": data["expires_in"],
"token_type": "bearer",
}
@router.post("/auth/refresh", tags=["Auth"])
async def refresh(request: RefreshRequest):
"""Rinnova l'access_token con il refresh_token."""
async with httpx.AsyncClient() as client:
resp = await client.post(
_supabase_auth_url("token?grant_type=refresh_token"),
headers=_SUPABASE_HEADERS,
json={"refresh_token": request.refresh_token},
timeout=10,
)
if resp.status_code != 200:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token non valido o scaduto — effettua nuovamente il login"
)
data = resp.json()
return {
"access_token": data["access_token"],
"refresh_token": data["refresh_token"],
"expires_in": data["expires_in"],
"token_type": "bearer",
}
@router.get("/auth/me", tags=["Auth"])
async def get_me(user: dict = Depends(get_current_user)):
"""Ritorna le informazioni dell'utente autenticato dal JWT."""
meta = user.get("user_metadata", {})
return {
"id": user.get("sub"),
"email": user.get("email"),
"name": meta.get("full_name") or meta.get("name"),
}
@router.post("/auth/logout", tags=["Auth"])
async def logout(request: RefreshRequest):
"""Invalida la sessione su Supabase tramite il refresh_token."""
async with httpx.AsyncClient() as client:
await client.post(
_supabase_auth_url("logout"),
headers=_SUPABASE_HEADERS,
json={"refresh_token": request.refresh_token},
timeout=10,
)
return {"success": True}

View file

@ -17,7 +17,7 @@ class EspositionRequest(BaseModel):
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: float = Field(default=1, description="Numero di applicazioni al giorno")
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")

View file

@ -3,7 +3,7 @@ from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from pif_compiler.classes.models import Ingredient, ToxIndicator
from pif_compiler.functions.db_utils import get_all_ingredienti, get_all_clienti, upsert_cliente, delete_cliente
from pif_compiler.functions.db_utils import get_all_ingredienti, get_all_clienti, upsert_cliente
from pif_compiler.functions.common_log import get_logger
logger = get_logger()
@ -206,29 +206,6 @@ async def list_clients():
)
@router.delete("/ingredients/clients/{nome_cliente}", tags=["Clients"])
async def remove_client(nome_cliente: str):
"""Elimina un cliente per nome. Fallisce se ha ordini collegati."""
result = delete_cliente(nome_cliente)
if result is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Il cliente '{nome_cliente}' ha ordini collegati e non può essere eliminato"
)
if result is False:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Errore interno durante l'eliminazione"
)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Cliente '{nome_cliente}' non trovato"
)
logger.info(f"Cliente '{nome_cliente}' eliminato")
return {"success": True, "nome_cliente": nome_cliente}
@router.post("/ingredients/clients", response_model=ClientCreateResponse, tags=["Clients"])
async def create_client(request: ClientCreateRequest):
"""Crea o recupera un cliente. Ritorna id_cliente."""

View file

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status
from fastapi import APIRouter, HTTPException, BackgroundTasks, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
@ -6,7 +6,6 @@ from typing import List, Optional, Dict, Any
from pif_compiler.classes.main_workflow import receive_order, process_order_pipeline, retry_order, trigger_pipeline, Project
from pif_compiler.functions.db_utils import db_connect, get_ordine_by_id, get_all_ordini, delete_ordine
from pif_compiler.functions.common_func import generate_project_source_pdfs, create_sources_zip
from pif_compiler.functions.auth import get_current_user
from pif_compiler.functions.common_log import get_logger
logger = get_logger()
@ -55,24 +54,17 @@ class OrderCreateResponse(BaseModel):
# ==================== ROUTES ====================
@router.post("/orders/create", response_model=OrderCreateResponse, tags=["Orders"])
async def create_order(
request: OrderCreateRequest,
background_tasks: BackgroundTasks,
user: dict = Depends(get_current_user),
):
async def create_order(request: OrderCreateRequest, background_tasks: BackgroundTasks):
"""
Crea un nuovo ordine e avvia l'elaborazione in background.
Il JSON viene salvato su MongoDB, il record su PostgreSQL (stato=RICEVUTO).
L'arricchimento degli ingredienti avviene in background.
Il compilatore viene valorizzato automaticamente dall'utente autenticato.
"""
meta = user.get("user_metadata", {})
compiler_name = meta.get("full_name") or meta.get("name") or user.get("email") or user.get("sub")
logger.info(f"Nuovo ordine ricevuto: cliente={request.client_name}, prodotto={request.product_name}, compilatore={compiler_name}")
logger.info(f"Nuovo ordine ricevuto: cliente={request.client_name}, prodotto={request.product_name}")
try:
raw_json = request.model_dump()
id_ordine = receive_order(raw_json, compiler_name=compiler_name)
id_ordine = receive_order(raw_json)
if id_ordine is None:
return OrderCreateResponse(

View file

@ -1,15 +1,13 @@
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, HttpUrl
from typing import Optional, Dict, Any, Literal
from datetime import datetime as dt
from typing import Optional, Dict, Any
import os
from pif_compiler.functions.common_func import generate_pdf
from pif_compiler.services.srv_pubchem import pubchem_dap
from pif_compiler.services.srv_cir import search_ingredient
from pif_compiler.functions.common_log import get_logger
from pif_compiler.functions.db_utils import db_connect
logger = get_logger()
@ -271,42 +269,6 @@ async def cir_search_endpoint(request: CirSearchRequest):
)
class SegnalazioneRequest(BaseModel):
cas: Optional[str] = None
page: str
description: str
error: Optional[str] = None
priority: Literal["bassa", "media", "alta"] = "media"
@router.post("/common/segnalazione", tags=["Common"])
async def create_segnalazione(request: SegnalazioneRequest):
"""Salva una segnalazione/ticket nella collection MongoDB 'segnalazioni'."""
collection = db_connect(collection_name="segnalazioni")
if collection is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Connessione MongoDB fallita"
)
doc = {
**request.model_dump(),
"created_at": dt.now().isoformat(),
"stato": "aperta",
}
try:
result = collection.insert_one(doc)
logger.info(f"Segnalazione creata: id={result.inserted_id}, page={request.page}, priority={request.priority}")
return {"success": True, "id": str(result.inserted_id)}
except Exception as e:
logger.error(f"create_segnalazione: errore MongoDB — {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Errore nel salvataggio della segnalazione"
)
@router.get("/common/health", tags=["Common"])
async def common_health_check():
"""

View file

@ -11,6 +11,7 @@ from pif_compiler.classes.models import (
ToxIndicator,
Toxicity,
Ingredient,
RetentionFactors,
Esposition,
)

View file

@ -14,9 +14,9 @@ from pif_compiler.classes.models import (
StatoOrdine, Ingredient, Esposition
)
from pif_compiler.functions.db_utils import (
db_connect, upsert_cliente, upsert_compilatore, aggiorna_stato_ordine,
db_connect, upsert_cliente, aggiorna_stato_ordine,
insert_ordine, get_oldest_pending_order,
update_ordine_cliente, update_ordine_compilatore, update_ordine_progetto, update_ordine_note,
update_ordine_cliente, update_ordine_progetto, update_ordine_note,
get_preset_id_by_name, insert_progetto,
insert_ingredient_lineage, get_ingrediente_id_by_cas,
get_ordine_by_id, reset_ordine_per_retry
@ -264,33 +264,25 @@ class Project(BaseModel):
# ==================== ORCHESTRATOR ====================
def receive_order(raw_json: dict, compiler_name: Optional[str] = None) -> Optional[int]:
def receive_order(raw_json: dict) -> Optional[int]:
"""
Riceve un ordine dal front-end, lo salva su MongoDB e crea il record in PostgreSQL.
Se compiler_name è fornito, crea/recupera il compilatore e lo lega subito all'ordine.
Ritorna id_ordine.
"""
# 1. Risolvi il compilatore prima di salvare, così è già nel record
id_compilatore = None
if compiler_name:
id_compilatore = upsert_compilatore(compiler_name)
if id_compilatore is None:
logger.warning(f"receive_order: upsert compilatore '{compiler_name}' fallito, ordine creato senza compilatore")
# 2. Salva il JSON grezzo su MongoDB collection 'orders'
# 1. Salva il JSON grezzo su MongoDB collection 'orders'
collection = db_connect(collection_name='orders')
result = collection.insert_one(raw_json.copy())
result = collection.insert_one(raw_json.copy()) # copy per evitare side-effects su _id
uuid_ordine = str(result.inserted_id)
logger.info(f"Ordine salvato su MongoDB: uuid_ordine={uuid_ordine}")
# 3. Crea il record nella tabella ordini (stato = RICEVUTO) con compilatore già valorizzato
id_ordine = insert_ordine(uuid_ordine, id_compilatore=id_compilatore)
# 2. Crea il record nella tabella ordini (stato = RICEVUTO)
id_ordine = insert_ordine(uuid_ordine)
if id_ordine is None:
logger.error(f"Errore creazione record ordini per uuid={uuid_ordine}")
return None
logger.info(f"Ordine {id_ordine} creato in PostgreSQL (stato=RICEVUTO, compilatore={'id=' + str(id_compilatore) if id_compilatore else 'n/d'})")
logger.info(f"Ordine {id_ordine} creato in PostgreSQL (stato=RICEVUTO)")
return id_ordine

View file

@ -127,7 +127,6 @@ class CosingInfo(BaseModel):
otherRestrictions : List[str] = Field(default_factory=list)
cosmeticRestriction : Optional[str] = None
reference : Optional[str] = None
substanceId : Optional[str] = None
sccsOpinionUrls : List[str] = Field(default_factory=list)
@classmethod
@ -141,7 +140,6 @@ class CosingInfo(BaseModel):
'otherRestrictions',
'cosmeticRestriction',
'reference',
'substanceId',
'inciName',
'sccsOpinionUrls'
]
@ -187,8 +185,6 @@ class CosingInfo(BaseModel):
cosing_dict['cosmeticRestriction'] = cosing_data[k]
if k == 'reference':
cosing_dict['reference'] = cosing_data[k]
if k == 'substanceId':
cosing_dict['substanceId'] = cosing_data[k]
if k == 'sccsOpinionUrls':
urls = []
for url in cosing_data[k]:
@ -217,7 +213,6 @@ class ToxIndicator(BaseModel):
toxicity_type : Optional[str] = None
ref : Optional[str] = None
source : Optional[str] = None
is_custom : bool = False
@property
def priority_rank(self):
@ -247,34 +242,9 @@ class Toxicity(BaseModel):
@model_validator(mode='after')
def set_best_case(self) -> 'Toxicity':
if not self.indicators:
return self
# 1. Pick highest priority rank (NOAEL=4 > LOAEL=3 > LD50=1)
max_rank = max(x.priority_rank for x in self.indicators)
top = [x for x in self.indicators if x.priority_rank == max_rank]
# 2. Tiebreak by route preference: dermal > oral > inhalation > other
def _route_order(ind: ToxIndicator) -> int:
r = ind.route.lower()
if "dermal" in r:
return 1
if "oral" in r:
return 2
if "inhalation" in r:
return 3
return 4
best_route = min(_route_order(x) for x in top)
top = [x for x in top if _route_order(x) == best_route]
# 3. For NOAEL/LOAEL, pick the lowest value (most conservative)
if top[0].indicator in ('NOAEL', 'LOAEL'):
self.best_case = min(top, key=lambda x: x.value)
else:
self.best_case = top[0]
self.factor = self.best_case.factor
if self.indicators:
self.best_case = max(self.indicators, key=lambda x: x.priority_rank)
self.factor = self.best_case.factor
return self
@classmethod
@ -422,10 +392,7 @@ class Ingredient(BaseModel):
@classmethod
def get_or_create(cls, cas: str, inci: Optional[List[str]] = None, force: bool = False):
"""Restituisce l'ingrediente dalla cache se esiste e non è vecchio, altrimenti lo ricrea.
Se force=True, ignora la cache e riesegue lo scraping aggiornando il documento.
Al re-scraping, i campi che risultano None vengono sostituiti con il valore cached
per evitare regressioni di dati in caso di fallimenti temporanei delle fonti esterne."""
cached = None
Se force=True, ignora la cache e riesegue lo scraping aggiornando il documento."""
if not force:
cached = cls.from_cas(cas)
if cached and not cached.is_old():
@ -438,26 +405,6 @@ class Ingredient(BaseModel):
logger.info(f"get_or_create CAS={cas}: force refresh")
ingredient = cls.ingredient_builder(cas, inci=inci)
if cached:
if ingredient.dap_info is None and cached.dap_info is not None:
logger.warning(f"get_or_create CAS={cas}: dap_info non ottenuto, mantengo dati cached")
ingredient.dap_info = cached.dap_info
if ingredient.cosing_info is None and cached.cosing_info is not None:
logger.warning(f"get_or_create CAS={cas}: cosing_info non ottenuto, mantengo dati cached")
ingredient.cosing_info = cached.cosing_info
if ingredient.toxicity is None and cached.toxicity is not None:
logger.warning(f"get_or_create CAS={cas}: toxicity non ottenuta, mantengo dati cached")
ingredient.toxicity = cached.toxicity
elif ingredient.toxicity is not None and cached.toxicity is not None:
custom_indicators = [i for i in cached.toxicity.indicators if i.is_custom]
if custom_indicators:
logger.info(f"get_or_create CAS={cas}: preservo {len(custom_indicators)} indicatori custom nel re-scraping")
ingredient.toxicity = Toxicity(
cas=ingredient.toxicity.cas,
indicators=ingredient.toxicity.indicators + custom_indicators
)
ingredient.save()
return ingredient
@ -505,7 +452,6 @@ class Ingredient(BaseModel):
def add_tox_indicator(self, indicator: ToxIndicator):
"""Aggiunge un indicatore tossicologico custom e ricalcola il best_case."""
indicator.is_custom = True
if self.toxicity is None:
self.toxicity = Toxicity(cas=self.cas, indicators=[indicator])
else:
@ -513,10 +459,15 @@ class Ingredient(BaseModel):
self.toxicity = Toxicity(cas=self.cas, indicators=new_indicators)
self.save()
class RetentionFactors:
LEAVE_ON = 1.0
RINSE_OFF = 0.01
DENTIFRICE = 0.05
MOUTHWASH = 0.10
DYE = 0.10
class Esposition(BaseModel):
preset_name: str
preset_name : str
tipo_prodotto: str
popolazione_target: str = "Adulti"
peso_target_kg: float = 60.0
@ -526,10 +477,10 @@ class Esposition(BaseModel):
esp_secondarie: List[str]
esp_nano: List[str]
sup_esposta: int = Field(ge=1, le=20000, description="Area di applicazione in cm2")
freq_applicazione: float = Field(default=1, description="Numero di applicazioni al giorno")
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.01, le=1.0, description="Fattore di ritenzione")
ritenzione: float = Field(default=1.0, ge=0, le=1.0, description="Fattore di ritenzione")
note: Optional[str] = None

View file

@ -1,70 +0,0 @@
import os
import jwt
from jwt import PyJWKClient
from fastapi import Request, HTTPException, status
from pif_compiler.functions.common_log import get_logger
logger = get_logger()
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
# Client JWKS inizializzato una volta sola — caching automatico delle chiavi pubbliche
_jwks_client: PyJWKClient | None = None
def _get_jwks_client() -> PyJWKClient:
global _jwks_client
if _jwks_client is None:
if not SUPABASE_URL:
logger.error("SUPABASE_URL non configurato")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Configurazione auth mancante sul server"
)
_jwks_client = PyJWKClient(f"{SUPABASE_URL}/auth/v1/.well-known/jwks.json")
return _jwks_client
def verify_jwt(token: str) -> dict:
"""Verifica il JWT Supabase tramite JWKS (RS256/ES256). Ritorna il payload decodificato."""
try:
client = _get_jwks_client()
signing_key = client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256", "ES256"],
audience="authenticated",
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token scaduto"
)
except jwt.InvalidTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token non valido: {e}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"verify_jwt: errore inatteso — {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Errore nella verifica del token"
)
def get_current_user(request: Request) -> dict:
"""FastAPI dependency: estrae e verifica il JWT dall'header Authorization."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header mancante o malformato"
)
token = auth_header.removeprefix("Bearer ")
return verify_jwt(token)

View file

@ -120,8 +120,7 @@ async def generate_project_source_pdfs(project, output_dir: str = "pdfs") -> lis
# --- Tox best_case PDF ---
best = ing.toxicity.best_case if ing.toxicity else None
if best and best.ref:
source_label = best.source or best.toxicity_type or "tox"
pdf_name = f"{pi.cas}_{source_label}"
pdf_name = f"{pi.cas}_{best.source}" if best.source else pi.cas
log.info(f"Generazione PDF tox: {pdf_name} da {best.ref}")
success = await generate_pdf(best.ref, pdf_name)
if success:
@ -130,27 +129,28 @@ async def generate_project_source_pdfs(project, output_dir: str = "pdfs") -> lis
log.warning(f"PDF tox non generato per {pdf_name}")
# --- COSING PDF ---
# Un solo PDF per ingrediente (il primo CosingInfo con reference valida).
if ing.cosing_info:
pdf_name = f"{pi.cas}_cosing"
pdf_path = os.path.join(output_dir, f"{pdf_name}.pdf")
seen_refs = set()
for cosing in ing.cosing_info:
if not cosing.reference or cosing.reference in seen_refs:
continue
seen_refs.add(cosing.reference)
if os.path.exists(pdf_path):
generated.append(pdf_path)
else:
reference = next(
(c.reference for c in ing.cosing_info if c.reference),
None
)
if reference:
log.info(f"Download COSING PDF: {pdf_name} (reference={reference})")
content = cosing_download(reference)
if isinstance(content, bytes):
with open(pdf_path, 'wb') as f:
f.write(content)
generated.append(pdf_path)
else:
log.warning(f"COSING PDF non scaricato per {pdf_name}: {content}")
pdf_name = f"{pi.cas}_cosing"
pdf_path = os.path.join(output_dir, f"{pdf_name}.pdf")
if os.path.exists(pdf_path):
generated.append(pdf_path)
continue
log.info(f"Download COSING PDF: {pdf_name} (ref={cosing.reference})")
content = cosing_download(cosing.reference)
if isinstance(content, bytes):
with open(pdf_path, 'wb') as f:
f.write(content)
generated.append(pdf_path)
else:
log.warning(f"COSING PDF non scaricato per {pdf_name}: {content}")
log.info(f"Generazione fonti completata: {len(generated)} PDF generati")
return generated
@ -189,14 +189,10 @@ def create_sources_zip(pdf_paths: list, zip_path: str) -> str:
if zip_dir:
os.makedirs(zip_dir, exist_ok=True)
seen_names: set[str] = set()
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for path in pdf_paths:
name = os.path.basename(path)
if not os.path.exists(path) or name in seen_names:
continue
seen_names.add(name)
zf.write(path, name)
if os.path.exists(path):
zf.write(path, os.path.basename(path))
log.info(f"ZIP creato: {zip_path} ({len(pdf_paths)} file)")
return zip_path

View file

@ -170,42 +170,6 @@ def get_all_clienti():
logger.error(f"Errore recupero clienti: {e}")
return []
def get_all_compilatori():
"""Recupera tutti i compilatori dalla tabella compilatori."""
try:
conn = postgres_connect()
with conn.cursor() as cur:
cur.execute("SELECT id_compilatore, nome_compilatore FROM compilatori ORDER BY nome_compilatore")
results = cur.fetchall()
conn.close()
return results if results else []
except Exception as e:
logger.error(f"Errore recupero compilatori: {e}")
return []
def delete_cliente(nome_cliente: str) -> bool:
"""Elimina un cliente per nome. Ritorna None se ha ordini collegati."""
try:
conn = postgres_connect()
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM ordini o JOIN clienti c ON o.id_cliente = c.id_cliente WHERE c.nome_cliente = %s",
(nome_cliente,)
)
count = cur.fetchone()[0]
if count > 0:
conn.close()
return None # segnale: cliente ha ordini, non eliminabile
cur.execute("DELETE FROM clienti WHERE nome_cliente = %s RETURNING id_cliente", (nome_cliente,))
deleted = cur.fetchone()
conn.commit()
conn.close()
return deleted is not None
except Exception as e:
logger.error(f"Errore eliminazione cliente '{nome_cliente}': {e}")
return False
def log_ricerche(cas, target, esito):
try:
conn = postgres_connect()
@ -217,7 +181,7 @@ def log_ricerche(cas, target, esito):
logger.error(f"Error: {e}")
return
def insert_ordine(uuid_ordine, id_cliente=None, id_compilatore=None):
def insert_ordine(uuid_ordine, id_cliente=None):
"""Inserisce un nuovo ordine nella tabella ordini. Ritorna id_ordine."""
from datetime import datetime as dt
from pif_compiler.classes.models import StatoOrdine
@ -225,9 +189,9 @@ def insert_ordine(uuid_ordine, id_cliente=None, id_compilatore=None):
conn = postgres_connect()
with conn.cursor() as cur:
cur.execute(
"""INSERT INTO ordini (uuid_ordine, id_cliente, id_compilatore, data_ordine, stato_ordine)
VALUES (%s, %s, %s, %s, %s) RETURNING id_ordine;""",
(uuid_ordine, id_cliente, id_compilatore, dt.now(), int(StatoOrdine.RICEVUTO))
"""INSERT INTO ordini (uuid_ordine, id_cliente, data_ordine, stato_ordine)
VALUES (%s, %s, %s, %s) RETURNING id_ordine;""",
(uuid_ordine, id_cliente, dt.now(), int(StatoOrdine.RICEVUTO))
)
result = cur.fetchone()
conn.commit()
@ -259,21 +223,6 @@ def get_oldest_pending_order():
logger.error(f"Errore recupero ordine pendente: {e}")
return None
def update_ordine_compilatore(id_ordine, id_compilatore):
"""Aggiorna id_compilatore sull'ordine."""
try:
conn = postgres_connect()
with conn.cursor() as cur:
cur.execute(
"UPDATE ordini SET id_compilatore = %s WHERE id_ordine = %s",
(id_compilatore, id_ordine)
)
conn.commit()
conn.close()
except Exception as e:
logger.error(f"Errore aggiornamento compilatore ordine {id_ordine}: {e}")
def update_ordine_cliente(id_ordine, id_cliente):
"""Aggiorna id_cliente sull'ordine."""
try:

View file

@ -1,20 +1,14 @@
from fastapi import Depends, FastAPI, Request, status
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from contextlib import asynccontextmanager
import os
import time
from dotenv import load_dotenv
from pif_compiler.functions.common_log import get_logger
load_dotenv()
# Import dei tuoi router
from pif_compiler.api.routes import api_echa, api_cosing, common, api_ingredients, api_esposition, api_orders, api_auth
from pif_compiler.functions.auth import get_current_user
from pif_compiler.api.routes import api_echa, api_cosing, common, api_ingredients, api_esposition, api_orders
# Configurazione logging
logger = get_logger()
@ -37,42 +31,28 @@ async def lifespan(app: FastAPI):
# - Cleanup risorse
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
ALLOWED_ORIGINS = [
"http://127.0.0.1",
"https://lmb.cosmoguard.it",
"http://localhost:8501", # Streamlit locale
]
TRUSTED_HOSTS = [
"lmb.cosmoguard.it",
"http://localhost",
"http://127.0.0.1",
"*"
]
# Inizializza FastAPI
app = FastAPI(
title="Comsoguard API",
description="Central API for Comsoguard services",
version="0.0.1",
docs_url="/docs" if ENVIRONMENT == "development" else None,
redoc_url="/redoc" if ENVIRONMENT == "development" else None,
openapi_url="/openapi.json" if ENVIRONMENT == "development" else None,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
lifespan=lifespan
)
# ==================== MIDDLEWARE ====================
# 1. Trusted Host — rifiuta richieste con Host header non autorizzato
app.add_middleware(TrustedHostMiddleware, allowed_hosts=TRUSTED_HOSTS)
# 2. CORS — solo origine autorizzata
# 1. CORS - Configura in base alle tue esigenze
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_origins=[
"*"
],
allow_credentials=True,
allow_methods=["GET", "POST", "DELETE"],
allow_methods=["*"],
allow_headers=["*"],
)
@ -135,52 +115,42 @@ async def general_exception_handler(request: Request, exc: Exception):
# ==================== ROUTERS ====================
_auth = [Depends(get_current_user)]
# Include i tuoi router qui
# Auth — pubblico (login, refresh, logout non richiedono token)
app.include_router(api_auth.router, prefix="/api/v1")
# Tutti gli altri router richiedono JWT valido
app.include_router(
api_echa.router,
prefix="/api/v1",
tags=["ECHA"],
dependencies=_auth,
tags=["ECHA"]
)
app.include_router(
api_cosing.router,
prefix="/api/v1",
tags=["COSING"],
dependencies=_auth,
tags=["COSING"]
)
app.include_router(
common.router,
prefix="/api/v1",
tags=["Common"],
dependencies=_auth,
tags=["Common"]
)
app.include_router(
api_ingredients.router,
prefix="/api/v1",
tags=["Ingredients"],
dependencies=_auth,
tags=["Ingredients"]
)
app.include_router(
api_esposition.router,
prefix="/api/v1",
tags=["Esposition"],
dependencies=_auth,
tags=["Esposition"]
)
app.include_router(
api_orders.router,
prefix="/api/v1",
tags=["Orders"],
dependencies=_auth,
tags=["Orders"]
)
# ==================== ROOT ENDPOINTS ====================

View file

@ -9,7 +9,7 @@ from playwright.sync_api import sync_playwright
from typing import Callable, Any
from pif_compiler.functions.common_log import get_logger
from pif_compiler.functions.db_utils import log_ricerche
from pif_compiler.functions.db_utils import db_connect, log_ricerche
log = get_logger()
load_dotenv()
@ -30,12 +30,12 @@ legislation = "&legislation=REACH"
def search_substance(cas : str) -> dict:
response = requests.get(BASE_SEARCH + cas)
if response.status_code != 200:
log.error(f"search_substance CAS={cas}: HTTP {response.status_code}")
log.error(f"Network error: {response.status_code}")
return {}
else:
response = response.json()
if response['state']['totalItems'] == 0:
log.warning(f"search_substance CAS={cas}: nessuna sostanza trovata su ECHA")
log.info(f"No substance found for CAS {cas}")
return {}
else:
for result in response['items']:
@ -47,9 +47,9 @@ def search_substance(cas : str) -> dict:
"rmlName": result["substanceIndex"]["rmlName"],
"rmlId": result["substanceIndex"]["rmlId"]
}
log.debug(f"search_substance CAS={cas}: trovata '{substance['rmlName']}'")
log.info(f"Substance found for CAS {cas}: {substance['rmlName']}")
return substance
log.warning(f"search_substance CAS={cas}: {response['state']['totalItems']} risultati ma nessun match esatto sul CAS")
log.error(f"Something went wrong searching the substance for CAS {cas}")
return {}
@ -57,16 +57,14 @@ def get_dossier_info(rmlId: str, type = active) -> dict:
url = BASE_DOSSIER + rmlId + type + legislation
response_dossier = requests.get(url)
if response_dossier.status_code != 200:
log.error(f"get_dossier_info rmlId={rmlId}: HTTP {response_dossier.status_code}")
log.error(f"Network error: {response_dossier.status_code}")
return {}
response_dossier_json = response_dossier.json()
if response_dossier_json['state']['totalItems'] == 0:
log.info(f"No dossier found for RML ID {rmlId}")
if type == active:
log.debug(f"get_dossier_info rmlId={rmlId}: nessun dossier attivo, provo inattivi")
return get_dossier_info(rmlId, inactive)
log.warning(f"get_dossier_info rmlId={rmlId}: nessun dossier trovato (né attivo né inattivo)")
return {}
dossier_info = {}
for dossier in response_dossier_json['items']:
if dossier['reachDossierInfo']['dossierSubtype'] == "Article 10 - full" and dossier['reachDossierInfo']['registrationRole'] == "Lead (joint submission)":
dossier_info = {
@ -77,8 +75,7 @@ def get_dossier_info(rmlId: str, type = active) -> dict:
"assetExternalId": dossier['assetExternalId'],
"rootKey": dossier['rootKey']
}
if not dossier_info:
log.warning(f"get_dossier_info rmlId={rmlId}: nessun dossier 'Article 10 - full / Lead' tra i {response_dossier_json['state']['totalItems']} trovati")
log.info(f"Dossier info retrieved for RML ID {rmlId}")
return dossier_info
@ -88,7 +85,7 @@ def get_substance_index(assetExternalId : str) -> dict:
response = requests.get(INDEX + "/index.html")
if response.status_code != 200:
log.error(f"get_substance_index {assetExternalId}: HTTP {response.status_code}")
log.error(f"Network error: {response.status_code}")
return {}
soup = BeautifulSoup(response.content, 'html.parser')
@ -101,7 +98,7 @@ def get_substance_index(assetExternalId : str) -> dict:
txi_href = txi_link['href']
index_data['toxicological_information_link'] = LINK_DOSSIER + txi_href + '.html'
except Exception as e:
log.warning(f"get_substance_index: link tossicologia non trovato — {e}")
log.error(f"Error retrieving toxicological information link: {e}")
index_data['toxicological_information_link'] = None
# Repeated dose toxicity : rdt
@ -111,7 +108,7 @@ def get_substance_index(assetExternalId : str) -> dict:
rdt_href = rdt_link['href']
index_data['repeated_dose_toxicity_link'] = LINK_DOSSIER + rdt_href + '.html'
except Exception as e:
log.warning(f"get_substance_index: link repeated dose non trovato — {e}")
log.error(f"Error retrieving repeated dose toxicity link: {e}")
index_data['repeated_dose_toxicity_link'] = None
# Acute toxicity : at
@ -121,9 +118,11 @@ def get_substance_index(assetExternalId : str) -> dict:
at_href = at_link['href']
index_data['acute_toxicity_link'] = LINK_DOSSIER + at_href + '.html'
except Exception as e:
log.warning(f"get_substance_index: link acute toxicity non trovato — {e}")
log.error(f"Error retrieving acute toxicity link: {e}")
index_data['acute_toxicity_link'] = None
log.info(f"Substance index retrieved for Asset External ID {assetExternalId}")
return index_data
@ -430,8 +429,8 @@ def echa_flow(cas) -> dict:
substance = search_substance(cas)
dossier_info = get_dossier_info(substance['rmlId'])
index = get_substance_index(dossier_info['assetExternalId'])
except KeyError as e:
log.error(f"echa_flow CAS={cas}: chiave mancante nella risposta ECHA — {e}")
except Exception as e:
log.error(f"Error in ECHA flow for CAS {cas}: {e}")
return {}
result = {
@ -443,14 +442,14 @@ def echa_flow(cas) -> dict:
"repeated_dose_toxicity": {}
}
log.debug(f"ECHA flow intermediate result")
# Fetch and parse toxicological information
txi_link = index.get('toxicological_information_link')
if txi_link:
response_summary = requests.get(txi_link)
if response_summary.status_code == 200:
result['toxicological_information'] = parse_toxicology_html(response_summary.content)
else:
log.warning(f"echa_flow CAS={cas}: tossicologia HTTP {response_summary.status_code}")
# Fetch and parse acute toxicity
at_link = index.get('acute_toxicity_link')
@ -458,8 +457,6 @@ def echa_flow(cas) -> dict:
response_acute = requests.get(at_link)
if response_acute.status_code == 200:
result['acute_toxicity'] = parse_toxicology_html(response_acute.content)
else:
log.warning(f"echa_flow CAS={cas}: acute toxicity HTTP {response_acute.status_code}")
# Fetch and parse repeated dose toxicity
rdt_link = index.get('repeated_dose_toxicity_link')
@ -467,41 +464,86 @@ def echa_flow(cas) -> dict:
response_repeated = requests.get(rdt_link)
if response_repeated.status_code == 200:
result['repeated_dose_toxicity'] = parse_toxicology_html(response_repeated.content)
for key, value in result.items():
if value is None or value == "" or value == [] or value == {}:
log.warning(f"Missing data for key: {key} in CAS {cas}")
else:
log.warning(f"echa_flow CAS={cas}: repeated dose HTTP {response_repeated.status_code}")
txi_ok = bool(result['toxicological_information'])
at_ok = bool(result['acute_toxicity'])
rdt_ok = bool(result['repeated_dose_toxicity'])
log.info(f"echa_flow CAS={cas}: txi={'OK' if txi_ok else '-'}, acute={'OK' if at_ok else '-'}, rdt={'OK' if rdt_ok else '-'}")
log.info(f"Data retrieved for key: {key} in CAS {cas}")
return result
def cas_validation(cas: str) -> str:
log.info(f"Starting ECHA data extraction for CAS: {cas}")
if cas is None or cas.strip() == "":
log.error("cas_validation: CAS vuoto o None")
log.error("No CAS number provided.")
return None
cas_stripped = cas.replace("-", "")
if cas_stripped.isdigit() and len(cas_stripped) <= 12:
log.info(f"CAS number {cas} maybe is valid.")
return cas.strip()
log.error(f"cas_validation: CAS '{cas}' non valido (formato non riconosciuto)")
return None
else:
log.error(f"CAS number {cas} is not valid.")
return None
def check_local(cas: str) -> bool:
collection = db_connect()
if collection is None:
log.error("No MongoDB collection available.")
return None
record = collection.find_one({"substance.rmlCas": cas})
if record:
log.info(f"Record for CAS {cas} found in local database.")
return record
else:
log.info(f"No record for CAS {cas} found in local database.")
return None
def add_to_local(data: dict) -> bool:
collection = db_connect()
if collection is None:
log.error("No MongoDB collection available.")
return False
try:
collection.insert_one(data)
log.info(f"Data for CAS {data['substance']['rmlCas']} added to local database.")
return True
except Exception as e:
log.error(f"Error inserting data into MongoDB: {e}")
return False
def orchestrator(cas: str) -> dict:
log.debug(f"ECHA orchestrator CAS={cas}")
log.debug(f"Initiating search for CAS {cas} in ECHA service.")
cas_validated = cas_validation(cas)
if not cas_validated:
return None
echa_data = echa_flow(cas_validated)
if echa_data:
log.info(f"ECHA CAS={cas}: completato")
log_ricerche(cas, 'ECHA', True)
return echa_data
else:
log.error(f"ECHA CAS={cas}: nessun dato recuperato")
log_ricerche(cas, 'ECHA', False)
return None
log.info(f"CAS {cas} validated successfully.")
local_record = check_local(cas_validated)
if local_record:
log.info(f"Returning local record for CAS {cas}.")
log_ricerche(cas, 'ECHA', True)
return local_record
else:
log.info(f"No local record, starting echa flow")
echa_data = echa_flow(cas_validated)
if echa_data:
log.info(f"Echa flow successful")
log_ricerche(cas, 'ECHA', True)
add_to_local(echa_data)
return echa_data
else:
log.error(f"Failed to retrieve ECHA data for CAS {cas}.")
log_ricerche(cas, 'ECHA', False)
return None
# to do: check if document is complete
# to do: check lastupdate
#endregion
if __name__ == "__main__":

132
uv.lock
View file

@ -348,59 +348,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" },
]
[[package]]
name = "cryptography"
version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289 },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637 },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742 },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528 },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993 },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855 },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635 },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038 },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181 },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482 },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497 },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819 },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230 },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909 },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287 },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728 },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287 },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291 },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539 },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199 },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131 },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072 },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170 },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741 },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728 },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001 },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637 },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487 },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514 },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349 },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667 },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980 },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143 },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674 },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801 },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755 },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539 },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794 },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160 },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123 },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220 },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050 },
]
[[package]]
name = "cssselect2"
version = "0.8.0"
@ -463,19 +410,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/79/4f544d73fcc0513b71296cb3ebb28a227d22e80dec27204977039b9fa875/duckdb-1.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:280fd663dacdd12bb3c3bf41f3e5b2e5b95e00b88120afabb8b8befa5f335c6f", size = 12336460, upload-time = "2025-10-07T10:37:12.154Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604 },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
@ -485,18 +419,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 },
]
[[package]]
name = "extra-streamlit-components"
version = "0.1.81"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "streamlit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/24/49/9b47a3450034d74259f9d4887d85be4e6a771bc21da467b253323d78c4d9/extra_streamlit_components-0.1.81.tar.gz", hash = "sha256:eb9beb7bacfe8b3d238f1888a21c78ac6cfa569341be484bca08c3ea0b15f20d", size = 2250141 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/8d/d2f1eeb52c50c990d14fd91bea35157890bb791c46b3f2bebaa5eef4bdf6/extra_streamlit_components-0.1.81-py3-none-any.whl", hash = "sha256:11a4651dbd03cac04edfbb8711757b1d10e3cdf280b8fa3a43f970d05e684619", size = 2278499 },
]
[[package]]
name = "fastapi"
version = "0.121.2"
@ -632,34 +554,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "idna"
version = "3.10"
@ -1087,12 +981,9 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "cryptography" },
{ name = "dotenv" },
{ name = "duckdb" },
{ name = "extra-streamlit-components" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "marimo" },
{ name = "markdown-to-json" },
{ name = "markdownify" },
@ -1101,8 +992,7 @@ dependencies = [
{ name = "psycopg2-binary" },
{ name = "pubchemprops" },
{ name = "pubchempy" },
{ name = "pydantic", extra = ["email"] },
{ name = "pyjwt" },
{ name = "pydantic" },
{ name = "pymongo" },
{ name = "pytest" },
{ name = "pytest-cov" },
@ -1117,12 +1007,9 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.14.2" },
{ name = "cryptography", specifier = ">=46.0.5" },
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "duckdb", specifier = ">=1.4.1" },
{ name = "extra-streamlit-components", specifier = ">=0.1.71" },
{ name = "fastapi", specifier = ">=0.121.2" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "marimo", specifier = ">=0.16.5" },
{ name = "markdown-to-json", specifier = ">=2.1.2" },
{ name = "markdownify", specifier = ">=1.2.0" },
@ -1131,8 +1018,7 @@ requires-dist = [
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "pubchemprops", specifier = ">=0.1.1" },
{ name = "pubchempy", specifier = ">=1.0.5" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.10" },
{ name = "pyjwt", specifier = ">=2.9.0" },
{ name = "pydantic", specifier = ">=2.11.10" },
{ name = "pymongo", specifier = ">=4.15.2" },
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
@ -1377,11 +1263,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/1f/73c53fcbfb0b5a78f91176df41945ca466e71e9d9d836e5c522abda39ee7/pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a", size = 444823, upload-time = "2025-10-04T10:40:39.055Z" },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
@ -1467,15 +1348,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyjwt"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224 },
]
[[package]]
name = "pymdown-extensions"
version = "10.16.1"