Compare commits
No commits in common. "7ef78b16900fb76ec848f31519d3dc85bc417755" and "448060155abfb1083975cf3c74059d15b2ec6b55" have entirely different histories.
7ef78b1690
...
448060155a
20 changed files with 420 additions and 29425 deletions
66
CLAUDE.md
66
CLAUDE.md
|
|
@ -29,7 +29,7 @@ src/pif_compiler/
|
||||||
│ ├── api_ingredients.py # Ingredient search by CAS + list all ingested + add tox indicator + clients CRUD
|
│ ├── 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_esposition.py # Esposition preset CRUD (create, list, delete)
|
||||||
│ ├── api_orders.py # Order creation, retry, manual pipeline trigger, Excel/PDF export
|
│ ├── 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/
|
├── classes/
|
||||||
│ ├── __init__.py # Re-exports all models from models.py and main_workflow.py
|
│ ├── __init__.py # Re-exports all models from models.py and main_workflow.py
|
||||||
│ ├── models.py # Pydantic models: Ingredient, DapInfo, CosingInfo,
|
│ ├── models.py # Pydantic models: Ingredient, DapInfo, CosingInfo,
|
||||||
|
|
@ -39,7 +39,6 @@ src/pif_compiler/
|
||||||
│ # orchestrator functions (receive_order, process_order_pipeline,
|
│ # orchestrator functions (receive_order, process_order_pipeline,
|
||||||
│ # retry_order, trigger_pipeline)
|
│ # retry_order, trigger_pipeline)
|
||||||
├── functions/
|
├── 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_func.py # PDF generation with Playwright, tox+COSING source PDF batch generation, COSING PDF download, ZIP creation
|
||||||
│ ├── common_log.py # Centralized logging configuration
|
│ ├── common_log.py # Centralized logging configuration
|
||||||
│ ├── db_utils.py # MongoDB + PostgreSQL connection helpers
|
│ ├── 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
|
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
|
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.)
|
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
|
### 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`
|
||||||
|
|
@ -109,7 +108,7 @@ POST /orders/create → receive_order() → BackgroundTasks → process_order_pi
|
||||||
On error → stato=9 (ERRORE) + note with error message
|
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`.
|
- **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.
|
- **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.
|
- **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:
|
- `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`)
|
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
|
- `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
|
- Exposed at `GET /orders/export-sources/{id_ordine}` — returns ZIP as FileResponse
|
||||||
|
|
||||||
### PostgreSQL schema (see `data/db_schema.sql`)
|
### 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/list` | List all orders with client/compiler/status info |
|
||||||
| GET | `/orders/detail/{id_ordine}` | Full order detail with ingredients from MongoDB |
|
| GET | `/orders/detail/{id_ordine}` | Full order detail with ingredients from MongoDB |
|
||||||
| DELETE | `/orders/{id_ordine}` | Delete order and all related data (PostgreSQL + 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/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 |
|
||||||
| POST | `/common/cir-search` | CIR ingredient text search |
|
| 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 |
|
| GET | `/health`, `/ping` | Health check endpoints |
|
||||||
|
|
||||||
Docs available at `/docs` (Swagger) and `/redoc`.
|
Docs available at `/docs` (Swagger) and `/redoc`.
|
||||||
|
|
@ -197,8 +191,6 @@ Configured via `.env` file (loaded with `python-dotenv`):
|
||||||
- `MONGO_HOST` - MongoDB host
|
- `MONGO_HOST` - MongoDB host
|
||||||
- `MONGO_PORT` - MongoDB port
|
- `MONGO_PORT` - MongoDB port
|
||||||
- `DATABASE_URL` - PostgreSQL connection string
|
- `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
|
## 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_cliente(nome_cliente)` - Upsert client, returns `id_cliente`
|
||||||
- `upsert_compilatore(nome_compilatore)` - Upsert compiler, returns `id_compilatore`
|
- `upsert_compilatore(nome_compilatore)` - Upsert compiler, returns `id_compilatore`
|
||||||
- `get_all_clienti()` - List all clients from PostgreSQL
|
- `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:**
|
**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_ordine_by_id(id_ordine)` - Get full order row
|
||||||
- `get_oldest_pending_order()` - Get oldest order with stato=RICEVUTO
|
- `get_oldest_pending_order()` - Get oldest order with stato=RICEVUTO
|
||||||
- `aggiorna_stato_ordine(id_ordine, nuovo_stato)` - Update order status
|
- `aggiorna_stato_ordine(id_ordine, nuovo_stato)` - Update order status
|
||||||
- `update_ordine_cliente(id_ordine, id_cliente)` - Set client on order
|
- `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_progetto(id_ordine, uuid_progetto)` - Set project UUID on order
|
||||||
- `update_ordine_note(id_ordine, note)` - Set note on order
|
- `update_ordine_note(id_ordine, note)` - Set note on order
|
||||||
- `reset_ordine_per_retry(id_ordine)` - Reset ERRORE order to RICEVUTO
|
- `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
|
- `log_ricerche(cas, target, esito)` - Log search history
|
||||||
|
|
||||||
### Streamlit UI
|
### Streamlit UI
|
||||||
|
- `streamlit/ingredients_page.py` - Ingredient search by CAS + result display + inventory of ingested ingredients
|
||||||
Entry point: `streamlit/app.py` (multi-page app via `st.navigation`). Run with:
|
- `streamlit/exposition_page.py` - Esposition preset creation form + list of existing presets
|
||||||
```bash
|
- `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 run streamlit/app.py
|
- `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`)
|
||||||
API must be running on `localhost:8000`.
|
- Run with: `streamlit run streamlit/<page>.py`
|
||||||
|
|
||||||
#### 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
|
|
||||||
|
|
||||||
### 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")
|
||||||
|
|
|
||||||
9331
logs/debug.log.1
9331
logs/debug.log.1
File diff suppressed because it is too large
Load diff
9686
logs/debug.log.2
9686
logs/debug.log.2
File diff suppressed because it is too large
Load diff
9708
logs/debug.log.3
9708
logs/debug.log.3
File diff suppressed because it is too large
Load diff
|
|
@ -19,21 +19,17 @@ dependencies = [
|
||||||
"psycopg2-binary>=2.9.11",
|
"psycopg2-binary>=2.9.11",
|
||||||
"pubchemprops>=0.1.1",
|
"pubchemprops>=0.1.1",
|
||||||
"pubchempy>=1.0.5",
|
"pubchempy>=1.0.5",
|
||||||
"pydantic[email]>=2.11.10",
|
"pydantic>=2.11.10",
|
||||||
"pymongo>=4.15.2",
|
"pymongo>=4.15.2",
|
||||||
"pytest>=8.4.2",
|
"pytest>=8.4.2",
|
||||||
"pytest-cov>=7.0.0",
|
"pytest-cov>=7.0.0",
|
||||||
"pytest-mock>=3.15.1",
|
"pytest-mock>=3.15.1",
|
||||||
"python-dotenv>=1.2.1",
|
"python-dotenv>=1.2.1",
|
||||||
"httpx>=0.27.0",
|
|
||||||
"PyJWT>=2.9.0",
|
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"streamlit>=1.50.0",
|
"streamlit>=1.50.0",
|
||||||
"extra-streamlit-components>=0.1.71",
|
|
||||||
"openpyxl>=3.1.0",
|
"openpyxl>=3.1.0",
|
||||||
"uvicorn>=0.35.0",
|
"uvicorn>=0.35.0",
|
||||||
"weasyprint>=66.0",
|
"weasyprint>=66.0",
|
||||||
"cryptography>=46.0.5",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
246
scripts/create_mock_order.py
Normal file
246
scripts/create_mock_order.py
Normal 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)
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -17,7 +17,7 @@ class EspositionRequest(BaseModel):
|
||||||
esp_secondarie: List[str] = Field(..., description="Vie di esposizione secondarie")
|
esp_secondarie: List[str] = Field(..., description="Vie di esposizione secondarie")
|
||||||
esp_nano: List[str] = Field(..., description="Vie di esposizione nano")
|
esp_nano: List[str] = Field(..., description="Vie di esposizione nano")
|
||||||
sup_esposta: int = Field(..., ge=1, le=17500, description="Area di applicazione in cm2")
|
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)")
|
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")
|
ritenzione: float = Field(default=1.0, ge=0, le=1.0, description="Fattore di ritenzione")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
|
|
||||||
from pif_compiler.classes.models import Ingredient, ToxIndicator
|
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
|
from pif_compiler.functions.common_log import get_logger
|
||||||
|
|
||||||
logger = 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"])
|
@router.post("/ingredients/clients", response_model=ClientCreateResponse, tags=["Clients"])
|
||||||
async def create_client(request: ClientCreateRequest):
|
async def create_client(request: ClientCreateRequest):
|
||||||
"""Crea o recupera un cliente. Ritorna id_cliente."""
|
"""Crea o recupera un cliente. Ritorna id_cliente."""
|
||||||
|
|
|
||||||
|
|
@ -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 fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional, Dict, Any
|
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.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.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.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
|
from pif_compiler.functions.common_log import get_logger
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
@ -55,24 +54,17 @@ class OrderCreateResponse(BaseModel):
|
||||||
# ==================== ROUTES ====================
|
# ==================== ROUTES ====================
|
||||||
|
|
||||||
@router.post("/orders/create", response_model=OrderCreateResponse, tags=["Orders"])
|
@router.post("/orders/create", response_model=OrderCreateResponse, tags=["Orders"])
|
||||||
async def create_order(
|
async def create_order(request: OrderCreateRequest, background_tasks: BackgroundTasks):
|
||||||
request: OrderCreateRequest,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Crea un nuovo ordine e avvia l'elaborazione in background.
|
Crea un nuovo ordine e avvia l'elaborazione in background.
|
||||||
Il JSON viene salvato su MongoDB, il record su PostgreSQL (stato=RICEVUTO).
|
Il JSON viene salvato su MongoDB, il record su PostgreSQL (stato=RICEVUTO).
|
||||||
L'arricchimento degli ingredienti avviene in background.
|
L'arricchimento degli ingredienti avviene in background.
|
||||||
Il compilatore viene valorizzato automaticamente dall'utente autenticato.
|
|
||||||
"""
|
"""
|
||||||
meta = user.get("user_metadata", {})
|
logger.info(f"Nuovo ordine ricevuto: cliente={request.client_name}, prodotto={request.product_name}")
|
||||||
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}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_json = request.model_dump()
|
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:
|
if id_ordine is None:
|
||||||
return OrderCreateResponse(
|
return OrderCreateResponse(
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, Field, HttpUrl
|
from pydantic import BaseModel, Field, HttpUrl
|
||||||
from typing import Optional, Dict, Any, Literal
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime as dt
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from pif_compiler.functions.common_func import generate_pdf
|
from pif_compiler.functions.common_func import generate_pdf
|
||||||
from pif_compiler.services.srv_pubchem import pubchem_dap
|
from pif_compiler.services.srv_pubchem import pubchem_dap
|
||||||
from pif_compiler.services.srv_cir import search_ingredient
|
from pif_compiler.services.srv_cir import search_ingredient
|
||||||
from pif_compiler.functions.common_log import get_logger
|
from pif_compiler.functions.common_log import get_logger
|
||||||
from pif_compiler.functions.db_utils import db_connect
|
|
||||||
|
|
||||||
logger = get_logger()
|
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"])
|
@router.get("/common/health", tags=["Common"])
|
||||||
async def common_health_check():
|
async def common_health_check():
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from pif_compiler.classes.models import (
|
||||||
ToxIndicator,
|
ToxIndicator,
|
||||||
Toxicity,
|
Toxicity,
|
||||||
Ingredient,
|
Ingredient,
|
||||||
|
RetentionFactors,
|
||||||
Esposition,
|
Esposition,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ from pif_compiler.classes.models import (
|
||||||
StatoOrdine, Ingredient, Esposition
|
StatoOrdine, Ingredient, Esposition
|
||||||
)
|
)
|
||||||
from pif_compiler.functions.db_utils import (
|
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,
|
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,
|
get_preset_id_by_name, insert_progetto,
|
||||||
insert_ingredient_lineage, get_ingrediente_id_by_cas,
|
insert_ingredient_lineage, get_ingrediente_id_by_cas,
|
||||||
get_ordine_by_id, reset_ordine_per_retry
|
get_ordine_by_id, reset_ordine_per_retry
|
||||||
|
|
@ -264,33 +264,25 @@ class Project(BaseModel):
|
||||||
|
|
||||||
# ==================== ORCHESTRATOR ====================
|
# ==================== 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.
|
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.
|
Ritorna id_ordine.
|
||||||
"""
|
"""
|
||||||
# 1. Risolvi il compilatore prima di salvare, così è già nel record
|
# 1. Salva il JSON grezzo su MongoDB collection 'orders'
|
||||||
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'
|
|
||||||
collection = db_connect(collection_name='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)
|
uuid_ordine = str(result.inserted_id)
|
||||||
|
|
||||||
logger.info(f"Ordine salvato su MongoDB: uuid_ordine={uuid_ordine}")
|
logger.info(f"Ordine salvato su MongoDB: uuid_ordine={uuid_ordine}")
|
||||||
|
|
||||||
# 3. Crea il record nella tabella ordini (stato = RICEVUTO) con compilatore già valorizzato
|
# 2. Crea il record nella tabella ordini (stato = RICEVUTO)
|
||||||
id_ordine = insert_ordine(uuid_ordine, id_compilatore=id_compilatore)
|
id_ordine = insert_ordine(uuid_ordine)
|
||||||
if id_ordine is None:
|
if id_ordine is None:
|
||||||
logger.error(f"Errore creazione record ordini per uuid={uuid_ordine}")
|
logger.error(f"Errore creazione record ordini per uuid={uuid_ordine}")
|
||||||
return None
|
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
|
return id_ordine
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,6 @@ class CosingInfo(BaseModel):
|
||||||
otherRestrictions : List[str] = Field(default_factory=list)
|
otherRestrictions : List[str] = Field(default_factory=list)
|
||||||
cosmeticRestriction : Optional[str] = None
|
cosmeticRestriction : Optional[str] = None
|
||||||
reference : Optional[str] = None
|
reference : Optional[str] = None
|
||||||
substanceId : Optional[str] = None
|
|
||||||
sccsOpinionUrls : List[str] = Field(default_factory=list)
|
sccsOpinionUrls : List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -141,7 +140,6 @@ class CosingInfo(BaseModel):
|
||||||
'otherRestrictions',
|
'otherRestrictions',
|
||||||
'cosmeticRestriction',
|
'cosmeticRestriction',
|
||||||
'reference',
|
'reference',
|
||||||
'substanceId',
|
|
||||||
'inciName',
|
'inciName',
|
||||||
'sccsOpinionUrls'
|
'sccsOpinionUrls'
|
||||||
]
|
]
|
||||||
|
|
@ -187,8 +185,6 @@ class CosingInfo(BaseModel):
|
||||||
cosing_dict['cosmeticRestriction'] = cosing_data[k]
|
cosing_dict['cosmeticRestriction'] = cosing_data[k]
|
||||||
if k == 'reference':
|
if k == 'reference':
|
||||||
cosing_dict['reference'] = cosing_data[k]
|
cosing_dict['reference'] = cosing_data[k]
|
||||||
if k == 'substanceId':
|
|
||||||
cosing_dict['substanceId'] = cosing_data[k]
|
|
||||||
if k == 'sccsOpinionUrls':
|
if k == 'sccsOpinionUrls':
|
||||||
urls = []
|
urls = []
|
||||||
for url in cosing_data[k]:
|
for url in cosing_data[k]:
|
||||||
|
|
@ -217,7 +213,6 @@ class ToxIndicator(BaseModel):
|
||||||
toxicity_type : Optional[str] = None
|
toxicity_type : Optional[str] = None
|
||||||
ref : Optional[str] = None
|
ref : Optional[str] = None
|
||||||
source : Optional[str] = None
|
source : Optional[str] = None
|
||||||
is_custom : bool = False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def priority_rank(self):
|
def priority_rank(self):
|
||||||
|
|
@ -247,33 +242,8 @@ class Toxicity(BaseModel):
|
||||||
|
|
||||||
@model_validator(mode='after')
|
@model_validator(mode='after')
|
||||||
def set_best_case(self) -> 'Toxicity':
|
def set_best_case(self) -> 'Toxicity':
|
||||||
if not self.indicators:
|
if self.indicators:
|
||||||
return self
|
self.best_case = max(self.indicators, key=lambda x: x.priority_rank)
|
||||||
|
|
||||||
# 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
|
self.factor = self.best_case.factor
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -422,10 +392,7 @@ class Ingredient(BaseModel):
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_or_create(cls, cas: str, inci: Optional[List[str]] = None, force: bool = False):
|
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.
|
"""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.
|
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
|
|
||||||
if not force:
|
if not force:
|
||||||
cached = cls.from_cas(cas)
|
cached = cls.from_cas(cas)
|
||||||
if cached and not cached.is_old():
|
if cached and not cached.is_old():
|
||||||
|
|
@ -438,26 +405,6 @@ class Ingredient(BaseModel):
|
||||||
logger.info(f"get_or_create CAS={cas}: force refresh")
|
logger.info(f"get_or_create CAS={cas}: force refresh")
|
||||||
|
|
||||||
ingredient = cls.ingredient_builder(cas, inci=inci)
|
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()
|
ingredient.save()
|
||||||
return ingredient
|
return ingredient
|
||||||
|
|
||||||
|
|
@ -505,7 +452,6 @@ class Ingredient(BaseModel):
|
||||||
|
|
||||||
def add_tox_indicator(self, indicator: ToxIndicator):
|
def add_tox_indicator(self, indicator: ToxIndicator):
|
||||||
"""Aggiunge un indicatore tossicologico custom e ricalcola il best_case."""
|
"""Aggiunge un indicatore tossicologico custom e ricalcola il best_case."""
|
||||||
indicator.is_custom = True
|
|
||||||
if self.toxicity is None:
|
if self.toxicity is None:
|
||||||
self.toxicity = Toxicity(cas=self.cas, indicators=[indicator])
|
self.toxicity = Toxicity(cas=self.cas, indicators=[indicator])
|
||||||
else:
|
else:
|
||||||
|
|
@ -513,7 +459,12 @@ class Ingredient(BaseModel):
|
||||||
self.toxicity = Toxicity(cas=self.cas, indicators=new_indicators)
|
self.toxicity = Toxicity(cas=self.cas, indicators=new_indicators)
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
class RetentionFactors:
|
||||||
|
LEAVE_ON = 1.0
|
||||||
|
RINSE_OFF = 0.01
|
||||||
|
DENTIFRICE = 0.05
|
||||||
|
MOUTHWASH = 0.10
|
||||||
|
DYE = 0.10
|
||||||
|
|
||||||
class Esposition(BaseModel):
|
class Esposition(BaseModel):
|
||||||
preset_name : str
|
preset_name : str
|
||||||
|
|
@ -526,10 +477,10 @@ class Esposition(BaseModel):
|
||||||
esp_secondarie: List[str]
|
esp_secondarie: List[str]
|
||||||
esp_nano: List[str]
|
esp_nano: List[str]
|
||||||
|
|
||||||
sup_esposta: int = Field(ge=1, le=20000, description="Area di applicazione in cm2")
|
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)")
|
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
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -120,8 +120,7 @@ async def generate_project_source_pdfs(project, output_dir: str = "pdfs") -> lis
|
||||||
# --- Tox best_case PDF ---
|
# --- Tox best_case PDF ---
|
||||||
best = ing.toxicity.best_case if ing.toxicity else None
|
best = ing.toxicity.best_case if ing.toxicity else None
|
||||||
if best and best.ref:
|
if best and best.ref:
|
||||||
source_label = best.source or best.toxicity_type or "tox"
|
pdf_name = f"{pi.cas}_{best.source}" if best.source else pi.cas
|
||||||
pdf_name = f"{pi.cas}_{source_label}"
|
|
||||||
log.info(f"Generazione PDF tox: {pdf_name} da {best.ref}")
|
log.info(f"Generazione PDF tox: {pdf_name} da {best.ref}")
|
||||||
success = await generate_pdf(best.ref, pdf_name)
|
success = await generate_pdf(best.ref, pdf_name)
|
||||||
if success:
|
if success:
|
||||||
|
|
@ -130,21 +129,22 @@ async def generate_project_source_pdfs(project, output_dir: str = "pdfs") -> lis
|
||||||
log.warning(f"PDF tox non generato per {pdf_name}")
|
log.warning(f"PDF tox non generato per {pdf_name}")
|
||||||
|
|
||||||
# --- COSING PDF ---
|
# --- COSING PDF ---
|
||||||
# Un solo PDF per ingrediente (il primo CosingInfo con reference valida).
|
|
||||||
if ing.cosing_info:
|
if ing.cosing_info:
|
||||||
|
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)
|
||||||
|
|
||||||
pdf_name = f"{pi.cas}_cosing"
|
pdf_name = f"{pi.cas}_cosing"
|
||||||
pdf_path = os.path.join(output_dir, f"{pdf_name}.pdf")
|
pdf_path = os.path.join(output_dir, f"{pdf_name}.pdf")
|
||||||
|
|
||||||
if os.path.exists(pdf_path):
|
if os.path.exists(pdf_path):
|
||||||
generated.append(pdf_path)
|
generated.append(pdf_path)
|
||||||
else:
|
continue
|
||||||
reference = next(
|
|
||||||
(c.reference for c in ing.cosing_info if c.reference),
|
log.info(f"Download COSING PDF: {pdf_name} (ref={cosing.reference})")
|
||||||
None
|
content = cosing_download(cosing.reference)
|
||||||
)
|
|
||||||
if reference:
|
|
||||||
log.info(f"Download COSING PDF: {pdf_name} (reference={reference})")
|
|
||||||
content = cosing_download(reference)
|
|
||||||
if isinstance(content, bytes):
|
if isinstance(content, bytes):
|
||||||
with open(pdf_path, 'wb') as f:
|
with open(pdf_path, 'wb') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
@ -189,14 +189,10 @@ def create_sources_zip(pdf_paths: list, zip_path: str) -> str:
|
||||||
if zip_dir:
|
if zip_dir:
|
||||||
os.makedirs(zip_dir, exist_ok=True)
|
os.makedirs(zip_dir, exist_ok=True)
|
||||||
|
|
||||||
seen_names: set[str] = set()
|
|
||||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
for path in pdf_paths:
|
for path in pdf_paths:
|
||||||
name = os.path.basename(path)
|
if os.path.exists(path):
|
||||||
if not os.path.exists(path) or name in seen_names:
|
zf.write(path, os.path.basename(path))
|
||||||
continue
|
|
||||||
seen_names.add(name)
|
|
||||||
zf.write(path, name)
|
|
||||||
|
|
||||||
log.info(f"ZIP creato: {zip_path} ({len(pdf_paths)} file)")
|
log.info(f"ZIP creato: {zip_path} ({len(pdf_paths)} file)")
|
||||||
return zip_path
|
return zip_path
|
||||||
|
|
|
||||||
|
|
@ -170,42 +170,6 @@ def get_all_clienti():
|
||||||
logger.error(f"Errore recupero clienti: {e}")
|
logger.error(f"Errore recupero clienti: {e}")
|
||||||
return []
|
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):
|
def log_ricerche(cas, target, esito):
|
||||||
try:
|
try:
|
||||||
conn = postgres_connect()
|
conn = postgres_connect()
|
||||||
|
|
@ -217,7 +181,7 @@ def log_ricerche(cas, target, esito):
|
||||||
logger.error(f"Error: {e}")
|
logger.error(f"Error: {e}")
|
||||||
return
|
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."""
|
"""Inserisce un nuovo ordine nella tabella ordini. Ritorna id_ordine."""
|
||||||
from datetime import datetime as dt
|
from datetime import datetime as dt
|
||||||
from pif_compiler.classes.models import StatoOrdine
|
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()
|
conn = postgres_connect()
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""INSERT INTO ordini (uuid_ordine, id_cliente, id_compilatore, data_ordine, stato_ordine)
|
"""INSERT INTO ordini (uuid_ordine, id_cliente, data_ordine, stato_ordine)
|
||||||
VALUES (%s, %s, %s, %s, %s) RETURNING id_ordine;""",
|
VALUES (%s, %s, %s, %s) RETURNING id_ordine;""",
|
||||||
(uuid_ordine, id_cliente, id_compilatore, dt.now(), int(StatoOrdine.RICEVUTO))
|
(uuid_ordine, id_cliente, dt.now(), int(StatoOrdine.RICEVUTO))
|
||||||
)
|
)
|
||||||
result = cur.fetchone()
|
result = cur.fetchone()
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
@ -259,21 +223,6 @@ def get_oldest_pending_order():
|
||||||
logger.error(f"Errore recupero ordine pendente: {e}")
|
logger.error(f"Errore recupero ordine pendente: {e}")
|
||||||
return None
|
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):
|
def update_ordine_cliente(id_ordine, id_cliente):
|
||||||
"""Aggiorna id_cliente sull'ordine."""
|
"""Aggiorna id_cliente sull'ordine."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -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.cors import CORSMiddleware
|
||||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from pif_compiler.functions.common_log import get_logger
|
from pif_compiler.functions.common_log import get_logger
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# Import dei tuoi router
|
# 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.api.routes import api_echa, api_cosing, common, api_ingredients, api_esposition, api_orders
|
||||||
from pif_compiler.functions.auth import get_current_user
|
|
||||||
|
|
||||||
# Configurazione logging
|
# Configurazione logging
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
@ -37,42 +31,28 @@ async def lifespan(app: FastAPI):
|
||||||
# - Cleanup risorse
|
# - 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
|
# Inizializza FastAPI
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Comsoguard API",
|
title="Comsoguard API",
|
||||||
description="Central API for Comsoguard services",
|
description="Central API for Comsoguard services",
|
||||||
version="0.0.1",
|
version="0.0.1",
|
||||||
docs_url="/docs" if ENVIRONMENT == "development" else None,
|
docs_url="/docs",
|
||||||
redoc_url="/redoc" if ENVIRONMENT == "development" else None,
|
redoc_url="/redoc",
|
||||||
openapi_url="/openapi.json" if ENVIRONMENT == "development" else None,
|
openapi_url="/openapi.json",
|
||||||
lifespan=lifespan
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==================== MIDDLEWARE ====================
|
# ==================== MIDDLEWARE ====================
|
||||||
|
|
||||||
# 1. Trusted Host — rifiuta richieste con Host header non autorizzato
|
# 1. CORS - Configura in base alle tue esigenze
|
||||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=TRUSTED_HOSTS)
|
|
||||||
|
|
||||||
# 2. CORS — solo origine autorizzata
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=ALLOWED_ORIGINS,
|
allow_origins=[
|
||||||
|
"*"
|
||||||
|
],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "DELETE"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -135,52 +115,42 @@ async def general_exception_handler(request: Request, exc: Exception):
|
||||||
|
|
||||||
# ==================== ROUTERS ====================
|
# ==================== 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(
|
app.include_router(
|
||||||
api_echa.router,
|
api_echa.router,
|
||||||
prefix="/api/v1",
|
prefix="/api/v1",
|
||||||
tags=["ECHA"],
|
tags=["ECHA"]
|
||||||
dependencies=_auth,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(
|
app.include_router(
|
||||||
api_cosing.router,
|
api_cosing.router,
|
||||||
prefix="/api/v1",
|
prefix="/api/v1",
|
||||||
tags=["COSING"],
|
tags=["COSING"]
|
||||||
dependencies=_auth,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(
|
app.include_router(
|
||||||
common.router,
|
common.router,
|
||||||
prefix="/api/v1",
|
prefix="/api/v1",
|
||||||
tags=["Common"],
|
tags=["Common"]
|
||||||
dependencies=_auth,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(
|
app.include_router(
|
||||||
api_ingredients.router,
|
api_ingredients.router,
|
||||||
prefix="/api/v1",
|
prefix="/api/v1",
|
||||||
tags=["Ingredients"],
|
tags=["Ingredients"]
|
||||||
dependencies=_auth,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(
|
app.include_router(
|
||||||
api_esposition.router,
|
api_esposition.router,
|
||||||
prefix="/api/v1",
|
prefix="/api/v1",
|
||||||
tags=["Esposition"],
|
tags=["Esposition"]
|
||||||
dependencies=_auth,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(
|
app.include_router(
|
||||||
api_orders.router,
|
api_orders.router,
|
||||||
prefix="/api/v1",
|
prefix="/api/v1",
|
||||||
tags=["Orders"],
|
tags=["Orders"]
|
||||||
dependencies=_auth,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==================== ROOT ENDPOINTS ====================
|
# ==================== ROOT ENDPOINTS ====================
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from playwright.sync_api import sync_playwright
|
||||||
from typing import Callable, Any
|
from typing import Callable, Any
|
||||||
|
|
||||||
from pif_compiler.functions.common_log import get_logger
|
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()
|
log = get_logger()
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
@ -30,12 +30,12 @@ legislation = "&legislation=REACH"
|
||||||
def search_substance(cas : str) -> dict:
|
def search_substance(cas : str) -> dict:
|
||||||
response = requests.get(BASE_SEARCH + cas)
|
response = requests.get(BASE_SEARCH + cas)
|
||||||
if response.status_code != 200:
|
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 {}
|
return {}
|
||||||
else:
|
else:
|
||||||
response = response.json()
|
response = response.json()
|
||||||
if response['state']['totalItems'] == 0:
|
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 {}
|
return {}
|
||||||
else:
|
else:
|
||||||
for result in response['items']:
|
for result in response['items']:
|
||||||
|
|
@ -47,9 +47,9 @@ def search_substance(cas : str) -> dict:
|
||||||
"rmlName": result["substanceIndex"]["rmlName"],
|
"rmlName": result["substanceIndex"]["rmlName"],
|
||||||
"rmlId": result["substanceIndex"]["rmlId"]
|
"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
|
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 {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -57,16 +57,14 @@ def get_dossier_info(rmlId: str, type = active) -> dict:
|
||||||
url = BASE_DOSSIER + rmlId + type + legislation
|
url = BASE_DOSSIER + rmlId + type + legislation
|
||||||
response_dossier = requests.get(url)
|
response_dossier = requests.get(url)
|
||||||
if response_dossier.status_code != 200:
|
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 {}
|
return {}
|
||||||
response_dossier_json = response_dossier.json()
|
response_dossier_json = response_dossier.json()
|
||||||
if response_dossier_json['state']['totalItems'] == 0:
|
if response_dossier_json['state']['totalItems'] == 0:
|
||||||
|
log.info(f"No dossier found for RML ID {rmlId}")
|
||||||
if type == active:
|
if type == active:
|
||||||
log.debug(f"get_dossier_info rmlId={rmlId}: nessun dossier attivo, provo inattivi")
|
|
||||||
return get_dossier_info(rmlId, inactive)
|
return get_dossier_info(rmlId, inactive)
|
||||||
log.warning(f"get_dossier_info rmlId={rmlId}: nessun dossier trovato (né attivo né inattivo)")
|
|
||||||
return {}
|
return {}
|
||||||
dossier_info = {}
|
|
||||||
for dossier in response_dossier_json['items']:
|
for dossier in response_dossier_json['items']:
|
||||||
if dossier['reachDossierInfo']['dossierSubtype'] == "Article 10 - full" and dossier['reachDossierInfo']['registrationRole'] == "Lead (joint submission)":
|
if dossier['reachDossierInfo']['dossierSubtype'] == "Article 10 - full" and dossier['reachDossierInfo']['registrationRole'] == "Lead (joint submission)":
|
||||||
dossier_info = {
|
dossier_info = {
|
||||||
|
|
@ -77,8 +75,7 @@ def get_dossier_info(rmlId: str, type = active) -> dict:
|
||||||
"assetExternalId": dossier['assetExternalId'],
|
"assetExternalId": dossier['assetExternalId'],
|
||||||
"rootKey": dossier['rootKey']
|
"rootKey": dossier['rootKey']
|
||||||
}
|
}
|
||||||
if not dossier_info:
|
log.info(f"Dossier info retrieved for RML ID {rmlId}")
|
||||||
log.warning(f"get_dossier_info rmlId={rmlId}: nessun dossier 'Article 10 - full / Lead' tra i {response_dossier_json['state']['totalItems']} trovati")
|
|
||||||
return dossier_info
|
return dossier_info
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -88,7 +85,7 @@ def get_substance_index(assetExternalId : str) -> dict:
|
||||||
|
|
||||||
response = requests.get(INDEX + "/index.html")
|
response = requests.get(INDEX + "/index.html")
|
||||||
if response.status_code != 200:
|
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 {}
|
return {}
|
||||||
|
|
||||||
soup = BeautifulSoup(response.content, 'html.parser')
|
soup = BeautifulSoup(response.content, 'html.parser')
|
||||||
|
|
@ -101,7 +98,7 @@ def get_substance_index(assetExternalId : str) -> dict:
|
||||||
txi_href = txi_link['href']
|
txi_href = txi_link['href']
|
||||||
index_data['toxicological_information_link'] = LINK_DOSSIER + txi_href + '.html'
|
index_data['toxicological_information_link'] = LINK_DOSSIER + txi_href + '.html'
|
||||||
except Exception as e:
|
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
|
index_data['toxicological_information_link'] = None
|
||||||
|
|
||||||
# Repeated dose toxicity : rdt
|
# Repeated dose toxicity : rdt
|
||||||
|
|
@ -111,7 +108,7 @@ def get_substance_index(assetExternalId : str) -> dict:
|
||||||
rdt_href = rdt_link['href']
|
rdt_href = rdt_link['href']
|
||||||
index_data['repeated_dose_toxicity_link'] = LINK_DOSSIER + rdt_href + '.html'
|
index_data['repeated_dose_toxicity_link'] = LINK_DOSSIER + rdt_href + '.html'
|
||||||
except Exception as e:
|
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
|
index_data['repeated_dose_toxicity_link'] = None
|
||||||
|
|
||||||
# Acute toxicity : at
|
# Acute toxicity : at
|
||||||
|
|
@ -121,9 +118,11 @@ def get_substance_index(assetExternalId : str) -> dict:
|
||||||
at_href = at_link['href']
|
at_href = at_link['href']
|
||||||
index_data['acute_toxicity_link'] = LINK_DOSSIER + at_href + '.html'
|
index_data['acute_toxicity_link'] = LINK_DOSSIER + at_href + '.html'
|
||||||
except Exception as e:
|
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
|
index_data['acute_toxicity_link'] = None
|
||||||
|
|
||||||
|
log.info(f"Substance index retrieved for Asset External ID {assetExternalId}")
|
||||||
|
|
||||||
return index_data
|
return index_data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -430,8 +429,8 @@ def echa_flow(cas) -> dict:
|
||||||
substance = search_substance(cas)
|
substance = search_substance(cas)
|
||||||
dossier_info = get_dossier_info(substance['rmlId'])
|
dossier_info = get_dossier_info(substance['rmlId'])
|
||||||
index = get_substance_index(dossier_info['assetExternalId'])
|
index = get_substance_index(dossier_info['assetExternalId'])
|
||||||
except KeyError as e:
|
except Exception as e:
|
||||||
log.error(f"echa_flow CAS={cas}: chiave mancante nella risposta ECHA — {e}")
|
log.error(f"Error in ECHA flow for CAS {cas}: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
|
|
@ -443,14 +442,14 @@ def echa_flow(cas) -> dict:
|
||||||
"repeated_dose_toxicity": {}
|
"repeated_dose_toxicity": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug(f"ECHA flow intermediate result")
|
||||||
|
|
||||||
# Fetch and parse toxicological information
|
# Fetch and parse toxicological information
|
||||||
txi_link = index.get('toxicological_information_link')
|
txi_link = index.get('toxicological_information_link')
|
||||||
if txi_link:
|
if txi_link:
|
||||||
response_summary = requests.get(txi_link)
|
response_summary = requests.get(txi_link)
|
||||||
if response_summary.status_code == 200:
|
if response_summary.status_code == 200:
|
||||||
result['toxicological_information'] = parse_toxicology_html(response_summary.content)
|
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
|
# Fetch and parse acute toxicity
|
||||||
at_link = index.get('acute_toxicity_link')
|
at_link = index.get('acute_toxicity_link')
|
||||||
|
|
@ -458,8 +457,6 @@ def echa_flow(cas) -> dict:
|
||||||
response_acute = requests.get(at_link)
|
response_acute = requests.get(at_link)
|
||||||
if response_acute.status_code == 200:
|
if response_acute.status_code == 200:
|
||||||
result['acute_toxicity'] = parse_toxicology_html(response_acute.content)
|
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
|
# Fetch and parse repeated dose toxicity
|
||||||
rdt_link = index.get('repeated_dose_toxicity_link')
|
rdt_link = index.get('repeated_dose_toxicity_link')
|
||||||
|
|
@ -467,41 +464,86 @@ def echa_flow(cas) -> dict:
|
||||||
response_repeated = requests.get(rdt_link)
|
response_repeated = requests.get(rdt_link)
|
||||||
if response_repeated.status_code == 200:
|
if response_repeated.status_code == 200:
|
||||||
result['repeated_dose_toxicity'] = parse_toxicology_html(response_repeated.content)
|
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:
|
else:
|
||||||
log.warning(f"echa_flow CAS={cas}: repeated dose HTTP {response_repeated.status_code}")
|
log.info(f"Data retrieved for key: {key} in CAS {cas}")
|
||||||
|
|
||||||
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 '-'}")
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def cas_validation(cas: str) -> str:
|
def cas_validation(cas: str) -> str:
|
||||||
|
log.info(f"Starting ECHA data extraction for CAS: {cas}")
|
||||||
if cas is None or cas.strip() == "":
|
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:
|
|
||||||
return cas.strip()
|
|
||||||
log.error(f"cas_validation: CAS '{cas}' non valido (formato non riconosciuto)")
|
|
||||||
return None
|
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()
|
||||||
|
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:
|
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)
|
cas_validated = cas_validation(cas)
|
||||||
if not cas_validated:
|
if not cas_validated:
|
||||||
return None
|
return None
|
||||||
|
else:
|
||||||
|
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)
|
echa_data = echa_flow(cas_validated)
|
||||||
if echa_data:
|
if echa_data:
|
||||||
log.info(f"ECHA CAS={cas}: completato")
|
log.info(f"Echa flow successful")
|
||||||
log_ricerche(cas, 'ECHA', True)
|
log_ricerche(cas, 'ECHA', True)
|
||||||
|
add_to_local(echa_data)
|
||||||
return echa_data
|
return echa_data
|
||||||
else:
|
else:
|
||||||
log.error(f"ECHA CAS={cas}: nessun dato recuperato")
|
log.error(f"Failed to retrieve ECHA data for CAS {cas}.")
|
||||||
log_ricerche(cas, 'ECHA', False)
|
log_ricerche(cas, 'ECHA', False)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# to do: check if document is complete
|
||||||
|
# to do: check lastupdate
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
132
uv.lock
132
uv.lock
|
|
@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "cssselect2"
|
name = "cssselect2"
|
||||||
version = "0.8.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "et-xmlfile"
|
name = "et-xmlfile"
|
||||||
version = "2.0.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.121.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.10"
|
version = "3.10"
|
||||||
|
|
@ -1087,12 +981,9 @@ version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "beautifulsoup4" },
|
{ name = "beautifulsoup4" },
|
||||||
{ name = "cryptography" },
|
|
||||||
{ name = "dotenv" },
|
{ name = "dotenv" },
|
||||||
{ name = "duckdb" },
|
{ name = "duckdb" },
|
||||||
{ name = "extra-streamlit-components" },
|
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "marimo" },
|
{ name = "marimo" },
|
||||||
{ name = "markdown-to-json" },
|
{ name = "markdown-to-json" },
|
||||||
{ name = "markdownify" },
|
{ name = "markdownify" },
|
||||||
|
|
@ -1101,8 +992,7 @@ dependencies = [
|
||||||
{ name = "psycopg2-binary" },
|
{ name = "psycopg2-binary" },
|
||||||
{ name = "pubchemprops" },
|
{ name = "pubchemprops" },
|
||||||
{ name = "pubchempy" },
|
{ name = "pubchempy" },
|
||||||
{ name = "pydantic", extra = ["email"] },
|
{ name = "pydantic" },
|
||||||
{ name = "pyjwt" },
|
|
||||||
{ name = "pymongo" },
|
{ name = "pymongo" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
|
|
@ -1117,12 +1007,9 @@ dependencies = [
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "beautifulsoup4", specifier = ">=4.14.2" },
|
{ name = "beautifulsoup4", specifier = ">=4.14.2" },
|
||||||
{ name = "cryptography", specifier = ">=46.0.5" },
|
|
||||||
{ name = "dotenv", specifier = ">=0.9.9" },
|
{ name = "dotenv", specifier = ">=0.9.9" },
|
||||||
{ name = "duckdb", specifier = ">=1.4.1" },
|
{ name = "duckdb", specifier = ">=1.4.1" },
|
||||||
{ name = "extra-streamlit-components", specifier = ">=0.1.71" },
|
|
||||||
{ name = "fastapi", specifier = ">=0.121.2" },
|
{ name = "fastapi", specifier = ">=0.121.2" },
|
||||||
{ name = "httpx", specifier = ">=0.27.0" },
|
|
||||||
{ name = "marimo", specifier = ">=0.16.5" },
|
{ name = "marimo", specifier = ">=0.16.5" },
|
||||||
{ name = "markdown-to-json", specifier = ">=2.1.2" },
|
{ name = "markdown-to-json", specifier = ">=2.1.2" },
|
||||||
{ name = "markdownify", specifier = ">=1.2.0" },
|
{ name = "markdownify", specifier = ">=1.2.0" },
|
||||||
|
|
@ -1131,8 +1018,7 @@ requires-dist = [
|
||||||
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
||||||
{ name = "pubchemprops", specifier = ">=0.1.1" },
|
{ name = "pubchemprops", specifier = ">=0.1.1" },
|
||||||
{ name = "pubchempy", specifier = ">=1.0.5" },
|
{ name = "pubchempy", specifier = ">=1.0.5" },
|
||||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.10" },
|
{ name = "pydantic", specifier = ">=2.11.10" },
|
||||||
{ name = "pyjwt", specifier = ">=2.9.0" },
|
|
||||||
{ name = "pymongo", specifier = ">=4.15.2" },
|
{ name = "pymongo", specifier = ">=4.15.2" },
|
||||||
{ name = "pytest", specifier = ">=8.4.2" },
|
{ name = "pytest", specifier = ">=8.4.2" },
|
||||||
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
version = "2.33.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pymdown-extensions"
|
name = "pymdown-extensions"
|
||||||
version = "10.16.1"
|
version = "10.16.1"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue