diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..06614ac --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,22 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Générer config.js depuis le template + run: | + sed 's|__API_URL__|${{ secrets.API_URL }}|g' \ + pages/static/js/config.template.js > pages/static/js/config.js + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./pages \ No newline at end of file diff --git a/.gitignore b/.gitignore index 11792ed..b085ac0 100644 --- a/.gitignore +++ b/.gitignore @@ -217,3 +217,7 @@ __marimo__/ backup/core/model/modelisation.py mpvrp_scoring.db + +temp/ + +pages/static/js/config.js diff --git a/Dockerfile b/Dockerfile index 640a5c7..b92de4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,19 @@ -FROM python:3.11-slim +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 WORKDIR /app -COPY requirements.txt . -RUN pip install -r requirements.txt +RUN pip install --no-cache-dir uv + +# Copy dependency metadata first to maximize Docker cache reuse. +COPY pyproject.toml uv.lock README.md ./ +RUN uv sync --frozen --no-dev --no-install-project COPY . . +RUN uv sync --frozen --no-dev EXPOSE 8000 -CMD ["bash", "/app/start.sh"] \ No newline at end of file +CMD ["uv", "run", "uvicorn", "backup.app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/README.md b/README.md index 0ecab4b..0af9733 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A comprehensive platform for generating, verifying, and evaluating solutions to - [Project Structure](#project-structure) - [Installation](#installation) - [Usage](#usage) - - [Quick Start Script](#quick-start-script) + - [Quick Start](#quick-start) - [API Endpoints](#api-endpoints) - [Web Interface](#web-interface) - [Development](#development) @@ -135,7 +135,8 @@ MPVRP-CC/ ### Prerequisites - Python 3.12+ -- pip or uv package manager +- `uv` package manager (recommended) +- `pip` (optional, for `requirements.txt` workflow) ### Setup @@ -145,23 +146,19 @@ MPVRP-CC/ cd MPVRP-CC ``` -2. **Create a virtual environment**: +2. **Install dependencies (recommended with uv)**: ```bash - python3.12 -m venv venv - source venv/bin/activate # On Windows: venv\Scripts\activate + uv sync ``` -3. **Install dependencies**: + Optional `pip` workflow: ```bash + python3.12 -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate pip install -r requirements.txt ``` - - Or using uv: - ```bash - uv sync - ``` -4. **Set up environment variables** (if needed): +3. **Set up environment variables** (if needed): Create a `.env` file in the root directory: ```bash # Example .env @@ -173,25 +170,30 @@ MPVRP-CC/ ## Usage -### Quick Start Script +### Quick Start -Use the helper script from the project root to launch the API with environment variables preconfigured: +From the project root, launch the API with `uv`: ```bash -./start.sh +export SECRET_KEY=your-secret-key-here +export DATABASE_URL=${DATABASE_URL:-sqlite:///./mpvrp_scoring.db} +uv run uvicorn backup.app.main:app --host 0.0.0.0 --port 8000 --workers 2 ``` -What it does: - -- Requires `SECRET_KEY` to be set in the environment before startup -- Exports `DATABASE_URL` (defaults to `sqlite:///./mpvrp_scoring.db` if not already set) -- Starts the server with `uvicorn backup.app.main:app --host 0.0.0.0 --port 8000 --workers 2` - After startup: - API: `http://localhost:8000` - Docs: `http://localhost:8000/docs` +### Docker + +Build and run with Docker: + +```bash +docker build -t mpvrp-cc . +docker run --rm -p 8000:8000 -e SECRET_KEY=your-secret-key-here mpvrp-cc +``` + ### API Endpoints #### 1. **Health Check** @@ -293,8 +295,7 @@ For complete API documentation, visit `/docs` when the server is running. 1. **Start the development server**: ```bash - cd backup - uvicorn app.main:app --reload + uv run uvicorn backup.app.main:app --reload ``` The API will be available at `http://localhost:8000` @@ -329,16 +330,16 @@ Key dependencies include: ```bash # Run all tests -pytest +uv run pytest # Run with coverage -pytest --cov=backup tests/ +uv run pytest --cov=backup tests/ # Run specific test file -pytest tests/test_integration.py +uv run pytest tests/test_integration.py # Run specific test -pytest tests/test_api.py::test_health_check +uv run pytest tests/test_api.py::TestApiEndpointsWithTestClient::test_health_endpoint ``` ### Code Structure Guidelines @@ -396,12 +397,12 @@ The project includes comprehensive test coverage: Run tests with pytest: ```bash -pytest +uv run pytest ``` Generate coverage report: ```bash -pytest --cov=backup --cov-report=html +uv run pytest --cov=backup --cov-report=html ``` --- diff --git a/backup/app/main.py b/backup/app/main.py index cc9b463..aea7fef 100644 --- a/backup/app/main.py +++ b/backup/app/main.py @@ -1,18 +1,17 @@ import os -import shutil import logging -from contextlib import asynccontextmanager from dotenv import load_dotenv from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -import backup.database.models_db as models -from backup.database.db import engine -from backup.app.routes import generator, model, scoring, scoreboard, auth +from backup.app.routes import generator, model, scoring, scoreboard load_dotenv() + +FRONTEND_DEV_URL = os.getenv("FRONTEND_DEV_URL", "") FRONTEND_PROD_URL = os.getenv("FRONTEND_PROD_URL", "") +FRONTEND_PROD_URL_2 = os.getenv("FRONTEND_PROD_URL_2", "") logging.basicConfig( level=os.getenv("LOG_LEVEL", "INFO").upper(), @@ -21,31 +20,14 @@ logger = logging.getLogger(__name__) -ALLOWED_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000", "https://ifri-ai-classes.github.io", FRONTEND_PROD_URL] +ALLOWED_ORIGINS = [FRONTEND_PROD_URL, FRONTEND_PROD_URL_2, FRONTEND_DEV_URL] logger.info("CORS allow-list configured with %s origin(s)", len(ALLOWED_ORIGINS)) -@asynccontextmanager -async def lifespan(app: FastAPI): - # Création des tables - models.Base.metadata.create_all(bind=engine) - - # Nettoyage de temp/ au démarrage - temp_dir = "temp" - if os.path.exists(temp_dir): - orphans = os.listdir(temp_dir) - if orphans: - logger.info("Startup cleanup: removing %s orphan file(s) in temp/", len(orphans)) - shutil.rmtree(temp_dir) - os.makedirs(temp_dir, exist_ok=True) - - yield - app = FastAPI( title="MPVRP-CC API", description="API for generating instances and verifying solutions to the MPVRP-CC problem (Multi-Product Vehicle Routing Problem with Changeover Cost)", version="1.0.0", - lifespan=lifespan ) # Configuration CORS pour permettre les appels depuis n'importe quelle origine @@ -54,14 +36,13 @@ async def lifespan(app: FastAPI): allow_origins=ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["*"], - allow_headers=["*"], + allow_headers=["*"] ) # Inclure les routes app.include_router(generator.router) app.include_router(model.router) app.include_router(scoring.router) -app.include_router(auth.router) app.include_router(scoreboard.router) @app.api_route("/", methods=["GET", "HEAD"], tags=["Root"]) diff --git a/backup/app/routes/auth.py b/backup/app/routes/auth.py deleted file mode 100644 index 6a56e35..0000000 --- a/backup/app/routes/auth.py +++ /dev/null @@ -1,65 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import OAuth2PasswordRequestForm -from sqlalchemy.orm import Session - -from backup.app.schemas import UserCreate, UserResponse, Token -from backup.database.db import get_db -from backup.database import models_db as models -from backup.core.auth import auth_logic - -router = APIRouter(prefix="/auth", tags=["Authentication"], include_in_schema=False) - -@router.post("/register", response_model=UserResponse) -async def register_team( - payload: UserCreate, - db: Session = Depends(get_db)): - """ - Register a new team in the database. - """ - # Check if user already exists - existing_user = db.query(models.User).filter( - (models.User.email == payload.email) | - (models.User.team_name == payload.team_name) - ).first() - - if existing_user: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Team name or team email already exists", - ) - - # Create new user with hashed password - new_user = models.User( - team_name=payload.team_name, - email=payload.email, - password_hash=auth_logic.hash_password(payload.password) - ) - - db.add(new_user) - db.commit() - db.refresh(new_user) - return new_user - -@router.post("/login", response_model=Token) -async def login_for_access_token( - form_data: OAuth2PasswordRequestForm = Depends(), - db: Session = Depends(get_db) -): - """ - Authenticate a team and return a JWT access token. - - The 'username' field in the form should be the user's email. - """ - user = db.query(models.User).filter(models.User.email == form_data.username).first() - - #password verification - if not user or not auth_logic.verify_password(form_data.password, user.password_hash): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - - #generate the token - access_token = auth_logic.create_access_token(data={"sub": str(user.id)}) - - return {"access_token": access_token, "token_type": "bearer"} \ No newline at end of file diff --git a/backup/app/routes/scoreboard.py b/backup/app/routes/scoreboard.py index bf526e2..577a0e9 100644 --- a/backup/app/routes/scoreboard.py +++ b/backup/app/routes/scoreboard.py @@ -1,70 +1,42 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session -from sqlalchemy import func +import os -from backup.database.db import get_db -from backup.database import models_db as models +from fastapi import APIRouter, HTTPException + +from backup.database.notion import get_all_entries, _extract_value from backup.app.schemas import LeaderboardEntry + router = APIRouter(prefix="/scoreboard", tags=["scoreboard"], include_in_schema=False) -@router.get("/", response_model=list[LeaderboardEntry]) -async def get_global_leaderboard(db: Session = Depends(get_db)): - """ - Retourne le leaderboard officiel : meilleure soumission par équipe. - - FIX : la jointure se fait sur submission.id (et non sur le score), - ce qui évite les doublons quand plusieurs soumissions ont le même score - """ - # 1/ pour chaque équipe, trouver l'id de sa meilleure soumission - #en gardant la soumission la plus récente (submitted_at le plus grand) - best_subquery = ( - db.query( - models.Submission.user_id, - func.min(models.Submission.total_weighted_score).label("min_score") - ) - .group_by(models.Submission.user_id) - .subquery() - ) +DATA_SOURCE_ID = os.getenv("NOTION_DATA_SOURCE_ID") + - # 2/ parmi les soumissions au score minimal, garder la plus récente - best_id_subquery = ( - db.query( - func.max(models.Submission.id).label("best_id") - ) - .join( - best_subquery, - (models.Submission.user_id == best_subquery.c.user_id) & - (models.Submission.total_weighted_score == best_subquery.c.min_score) - ) - .group_by(models.Submission.user_id) - .subquery() - ) +@router.get("", response_model=list[LeaderboardEntry]) +async def get_global_leaderboard(): + """Retourne le leaderboard trié par rang depuis Notion.""" + if not DATA_SOURCE_ID: + raise HTTPException(status_code=500, detail="NOTION_DATA_SOURCE_ID is not configured") - # 3/récupérer les données complètes pour ces soumissions uniquement - query = ( - db.query( - models.User.team_name, - models.Submission.id, - models.Submission.total_weighted_score, - models.Submission.total_feasible_count, - models.Submission.submitted_at - ) - .join(best_id_subquery, models.Submission.id == best_id_subquery.c.best_id) - .join(models.User, models.User.id == models.Submission.user_id) - .order_by(models.Submission.total_weighted_score.asc()) - .all() - ) + entries = get_all_entries(DATA_SOURCE_ID) leaderboard = [] - for i, row in enumerate(query): - leaderboard.append({ - "rank": i + 1, - "team": row.team_name, - "score": round(row.total_weighted_score, 2), - "instances_validated": f"{row.total_feasible_count}/150", - # FIX DATE : .isoformat() pour cohérence avec scoring router - "last_submission": row.submitted_at.isoformat() - }) + for entry in entries: + props = entry["properties"] + rank = _extract_value(props.get("Rank", {})) + if rank is None: + continue + + team = _extract_value(props.get("Name", {})) or _extract_value(props.get("Email", {})) or "—" + feasible = _extract_value(props.get("Feasible solutions", {})) or 0 + submitted_at = _extract_value(props.get("Submission date", {})) or "—" + + leaderboard.append(LeaderboardEntry( + rank=int(rank), + team=team, + score=round(_extract_value(props.get("Score", {})) or 0, 2), + instances_validated=f"{int(feasible)}/150", + last_submission=submitted_at, + )) + leaderboard.sort(key=lambda x: x.rank) return leaderboard \ No newline at end of file diff --git a/backup/app/routes/scoring.py b/backup/app/routes/scoring.py index 25f7ff4..c759510 100644 --- a/backup/app/routes/scoring.py +++ b/backup/app/routes/scoring.py @@ -2,53 +2,27 @@ import os import shutil import uuid +from datetime import datetime, timezone +from typing import Optional -from fastapi import APIRouter, Depends, UploadFile, File, BackgroundTasks, HTTPException -from sqlalchemy.orm import Session +from fastapi import APIRouter, UploadFile, File, BackgroundTasks, HTTPException, Form -from backup.database.db import get_db, SessionLocal -from backup.database import models_db as models from backup.core.scoring.score_evaluation import process_full_submission -from backup.core.auth.auth_logic import get_current_user -from backup.app.schemas import SubmissionResultResponse, TeamHistoryResponse +from backup.database.notion import upsert_submission +from backup.app.schemas import SubmissionResultResponse router = APIRouter(prefix="/scoring", tags=["Scoring"], include_in_schema=False) - -def run_scoring_in_background(submission_id: int, zip_path: str): - """Worker avec sa propre session DB — indépendante de la session HTTP.""" - db = SessionLocal() - try: - process_full_submission(submission_id, zip_path, db) - finally: - db.close() - - -def serialize_datetime(dt) -> str: - """ - Convertit un datetime Python ou une string SQLite en ISO 8601 avec T. - SQLAlchemy + SQLite peut retourner soit un datetime, soit une string brute - selon le driver: on normalise les deux cas ici. - """ - if dt is None: - return None - if hasattr(dt, 'isoformat'): #objet datetime Python - return dt.isoformat() - return str(dt).replace(' ', 'T') #string SQLite "2026-03-23 10:00:00" +DATA_SOURCE_ID = os.getenv("NOTION_DATA_SOURCE_ID") -@router.post("/submit") +@router.post("/submit", response_model=SubmissionResultResponse) async def submit_solutions_endpoint( background_tasks: BackgroundTasks, file: UploadFile = File(...), - db: Session = Depends(get_db), - current_user: models.User = Depends(get_current_user) + email: str = Form(...), + name: Optional[str] = Form(None), ): - user_id = current_user.id - user = db.query(models.User).filter(models.User.id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="Utilisateur/Équipe non trouvé.e") - if not file.filename.endswith('.zip'): raise HTTPException(status_code=400, detail="Seuls les fichiers .zip sont acceptés") @@ -62,107 +36,30 @@ async def submit_solutions_endpoint( except Exception as e: raise HTTPException(status_code=500, detail=f"Erreur lors de la sauvegarde : {str(e)}") - new_submission = models.Submission( - user_id=user_id, - total_weighted_score=0.0, - is_fully_feasible=False - ) - db.add(new_submission) - db.commit() - db.refresh(new_submission) - - background_tasks.add_task(run_scoring_in_background, new_submission.id, temp_path) - - return { - "status": "Accepted", - "submission_id": new_submission.id, - "team": user.team_name, - "message": "Le calcul de votre score a débuté. Les résultats seront bientôt disponibles sur le leaderboard." - } - - -@router.get("/result/{submission_id}", response_model=SubmissionResultResponse) -async def get_submission_result( - submission_id: int, - db: Session = Depends(get_db), - current_user: models.User = Depends(get_current_user) -): - submission = db.query(models.Submission).filter(models.Submission.id == submission_id).first() - - if not submission: - raise HTTPException(status_code=404, detail="Soumission non trouvée") - - if submission.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Accès non autorisé à cette soumission") - - db.refresh(submission) - - is_ready = submission.total_weighted_score is not None and submission.total_weighted_score > 0 - - results = [] - if is_ready: - results = db.query(models.InstanceResult).filter( - models.InstanceResult.submission_id == submission_id - ).all() - - detailed_results = [ - { - "instance": r.instance_name, - "category": r.category, - "feasible": r.is_feasible, - "distance": r.calculated_distance, - "transition_cost": r.calculated_transition_cost, - "errors": json.loads(r.errors_log) if r.errors_log else [] - } - for r in results - ] - - return { - "submission_id": submission.id, - "submitted_at": serialize_datetime(submission.submitted_at), - "total_score": submission.total_weighted_score, - "is_fully_feasible": submission.is_fully_feasible, - "total_valid_instances": f"{submission.total_feasible_count}/150", - "total_valid_instances_per_category": submission.category_stats, - "is_ready": is_ready, - "processor_info": submission.processor_info, - "instances_details": detailed_results - } - - -@router.get("/history", response_model=TeamHistoryResponse) -async def get_user_submission_history( - current_user: models.User = Depends(get_current_user), - db: Session = Depends(get_db) -): - user = db.query(models.User).filter(models.User.id == current_user.id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - submissions = ( - db.query(models.Submission) - .filter(models.Submission.user_id == current_user.id) - .order_by(models.Submission.submitted_at.asc()) - .all() - ) - - history = [] - for number, sub in enumerate(submissions, start=1): - history.append({ - "submission_id": sub.id, - # Numéro relatif à l'équipe : 1, 2, 3... peu importe l'id global - "submission_number": number, - "submitted_at": serialize_datetime(sub.submitted_at), - "score": round(sub.total_weighted_score, 2), - "valid_instances": f"{sub.total_feasible_count}/150", - "is_fully_feasible": sub.is_fully_feasible - }) - - # Réordonne du plus récent au plus ancien pour l'affichage - history.reverse() + # Évaluation synchrone — le résultat est retourné directement + result = process_full_submission(temp_path) + submitted_at = datetime.now(timezone.utc).isoformat() + submission_id = int(uuid.uuid4().int % 10**12) + + # Upsert vers Notion en arrière-plan (non bloquant pour l'utilisateur) + if DATA_SOURCE_ID: + background_tasks.add_task( + upsert_submission, + data_source_id=DATA_SOURCE_ID, + email=email, + score=result["total_weighted_score"], + feasible_solutions=result["total_feasible_count"], + name=name, + ) return { - "team_name": user.team_name, - "total_submissions": len(history), - "history": history + "submission_id": submission_id, + "submitted_at": submitted_at, + "total_score": round(result["total_weighted_score"], 2), + "is_fully_feasible": result["is_fully_feasible"], + "total_valid_instances": f"{result['total_feasible_count']}/150", + "total_valid_instances_per_category": json.dumps(result["category_stats"]), + "is_ready": True, + "processor_info": result["processor_info"], + "instances_details": result["instance_results"], } \ No newline at end of file diff --git a/backup/app/schemas.py b/backup/app/schemas.py index 009fb6e..65ad128 100644 --- a/backup/app/schemas.py +++ b/backup/app/schemas.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, Field, EmailStr from typing import Optional +from pydantic import BaseModel, Field + class InstanceGenerationRequest(BaseModel): """Parameters for generating an MPVRP-CC instance""" @@ -31,18 +32,6 @@ class SolutionVerificationResponse(BaseModel): metrics: dict -class UserBase(BaseModel): - team_name: str - email: EmailStr - -class UserCreate(UserBase): - password: str - -class UserResponse(UserBase): - id: int - class Config: - from_attributes = True - #SCORING & RESULTS class InstanceDetail(BaseModel): @@ -61,42 +50,17 @@ class SubmissionResultResponse(BaseModel): total_valid_instances: str total_valid_instances_per_category: Optional[str] = None is_ready: bool - processor_info: Optional[str] = None #rapport de structure du ZIP + processor_info: Optional[str] = None instances_details: list[InstanceDetail] class Config: from_attributes = True -# HISTORIQUE -class HistoryEntry(BaseModel): - submission_id: int - submission_number: int - submitted_at: str - score: float - valid_instances: str - is_fully_feasible: bool - -class TeamHistoryResponse(BaseModel): - team_name: str - total_submissions: int - history: list[HistoryEntry] - - -#LEADERBOARD - class LeaderboardEntry(BaseModel): rank: int team: str score: float instances_validated: str - last_submission: str # Important que ce soit en str, on envoie .isoformat() - - -# JWT -class Token(BaseModel): - access_token: str - token_type: str + last_submission: Optional[str] = None -class TokenData(BaseModel): - user_id: Optional[str] = None \ No newline at end of file diff --git a/backup/app/utils.py b/backup/app/utils.py deleted file mode 100644 index e69de29..0000000 diff --git a/backup/core/auth/__init__.py b/backup/core/auth/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backup/core/auth/auth_logic.py b/backup/core/auth/auth_logic.py deleted file mode 100644 index da6413e..0000000 --- a/backup/core/auth/auth_logic.py +++ /dev/null @@ -1,65 +0,0 @@ -import os -from jose import JWTError, jwt -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer -from sqlalchemy.orm import Session - -from backup.database.db import get_db -from backup.database import models_db as models - -from datetime import datetime, timedelta, timezone -from passlib.context import CryptContext -from dotenv import load_dotenv - -load_dotenv() - -SECRET_KEY = os.getenv("SECRET_KEY") -ALGORITHM = os.getenv("ALGORITHM", "HS256") #"HS256" par défaut -ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", 1440)) #24h par défaut - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - -def hash_password(password: str) -> str: - '''Transforme une suite de caractères en password hashé''' - return pwd_context.hash(password) - -def verify_password(plain_password: str, hashed_password: str) -> bool: - return pwd_context.verify(plain_password, hashed_password) - -def create_access_token(data: dict): - to_encode = data.copy() - expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt - -# Logique pour verrouiller les routes -# Cette ligne indique à FastAPI que le token doit se trouver dans le header "Authorization" -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") -# OAuth2PasswordBearer(tokenUrl="auth/login") - -async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): - """ - Cette dépendance vérifie la validité du jeton JWT et retourne l'utilisateur actuel. - En cas de jeton invalide, une erreur 401 Unauthorized est levée. - """ - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - try: - # Décoder le token - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - user_id: str = payload.get("sub") - if user_id is None: - raise credentials_exception - except JWTError: - raise credentials_exception - - #Chercher l'utilisateur - user = db.query(models.User).filter(models.User.id == int(user_id)).first() - if user is None: - raise credentials_exception - - return user \ No newline at end of file diff --git a/backup/core/model/feasibility.py b/backup/core/model/feasibility.py index b441697..f36bf75 100644 --- a/backup/core/model/feasibility.py +++ b/backup/core/model/feasibility.py @@ -7,169 +7,169 @@ def verify_solution(instance: Instance, solution: ParsedSolutionDat) -> Tuple[List[str], Dict[str, Any]]: """ - Vérifier la faisabilité et la cohérence d'une solution du MPVRP-CC. + Verify the feasibility and consistency of a solution of the MPVRP-CC. - Cette fonction effectue les vérifications suivantes : + This function performs the following checks: - 1. Cohérence des véhicules : départ/arrivée au bon garage - 2. Respect des capacités : pas de dépassement de la capacité des camions - 3. Conservation de la masse : quantité chargée = quantité livrée pour chaque segment - 4. Satisfaction de la demande : toutes les stations reçoivent les quantités demandées - 5. Respect des stocks : les dépôts ne sont pas sur-prélevés - 6. Validation des métriques : comparaison avec les valeurs calculées + 1. Vehicle consistency: departure/arrival at the correct garage + 2. Capacity compliance: no exceedance of truck capacity + 3. Mass conservation: quantity loaded = quantity delivered for each segment + 4. Demand satisfaction: all stations receive requested quantities + 5. Stock compliance: depots are not over-withdrawn + 6. Metric validation: comparison with calculated values Parameters ---------- instance : Instance - L'instance du problème MPVRP-CC. + The MPVRP-CC problem instance. solution : ParsedSolutionDat - La solution à vérifier. + The solution to verify. Returns ------- Tuple[List[str], Dict[str, Any]] - Une liste d'erreurs détectées (vide si la solution est faisable) et un dictionnaire des métriques recalculées. + A list of detected errors (empty if solution is feasible) and a dictionary of recalculated metrics. """ errors: List[str] = [] - # Créer des tables de correspondance pour un accès rapide aux entités par ID numérique + # Create lookup tables for fast access to entities by numeric ID vehicle_by_id = {int(k[1:]): v for k, v in instance.camions.items()} depot_by_id = {int(k[1:]): v for k, v in instance.depots.items()} station_by_id = {int(k[1:]): v for k, v in instance.stations.items()} - # Accumulateurs pour vérifier les livraisons et les chargements globaux - deliveries: Dict[Tuple[str, int], float] = {} # (station_id, product) -> quantité totale livrée - loads: Dict[Tuple[str, int], float] = {} # (depot_id, product) -> quantité totale chargée + # Accumulators for verifying deliveries and total loads + deliveries: Dict[Tuple[str, int], float] = {} # (station_id, product) -> total quantity delivered + loads: Dict[Tuple[str, int], float] = {} # (depot_id, product) -> total quantity loaded - # Métriques recalculées pour validation - computed_total_changes = 0 # Nombre de changements de produits - computed_total_switch_cost = 0.0 # Coût total des changements de produits - computed_distance_total = 0.0 # Distance totale parcourue par tous les véhicules + # Recalculated metrics for validation + computed_total_changes = 0 # Number of product changes + computed_total_switch_cost = 0.0 # Total cost of product changes + computed_distance_total = 0.0 # Total distance traveled by all vehicles - # Parcourir chaque véhicule de la solution + # Iterate through each vehicle in the solution for v in solution.vehicles: - # Vérifier que le véhicule existe dans l'instance + # Check that the vehicle exists in the instance camion = vehicle_by_id.get(v.vehicle_id) if camion is None: - errors.append(f"Véhicule {v.vehicle_id}: absent de l'instance") + errors.append(f"Vehicle {v.vehicle_id}: missing from instance") continue - # Vérifier que les listes de nœuds et de produits ont la même longueur + # Check that node and product lists have the same length if len(v.nodes) != len(v.products): errors.append( - f"Véhicule {v.vehicle_id}: incompatibilité longueur route/produits ({len(v.nodes)} vs {len(v.products)})" + f"Vehicle {v.vehicle_id}: incompatible route/product lengths ({len(v.nodes)} vs {len(v.products)})" ) continue - # Convertir les nœuds en clés formatées (ex: "D1", "S5", "G2") + # Convert nodes to formatted keys (e.g., "D1", "S5", "G2") keyed_nodes = [solution_node_key(n["kind"], n["id"]) for n in v.nodes] if not keyed_nodes: - errors.append(f"Véhicule {v.vehicle_id}: route vide") + errors.append(f"Vehicle {v.vehicle_id}: empty route") continue - # Vérifier que le véhicule démarre et termine au bon garage + # Check that the vehicle departs and returns to the correct garage expected_garage = camion.garage_id if keyed_nodes[0] != expected_garage or keyed_nodes[-1] != expected_garage: errors.append( - f"Véhicule {v.vehicle_id}: garage incohérent (attendu {expected_garage}, obtenu {keyed_nodes[0]}..{keyed_nodes[-1]})" + f"Vehicle {v.vehicle_id}: inconsistent garage (expected {expected_garage}, got {keyed_nodes[0]}..{keyed_nodes[-1]})" ) - # Calculer la distance totale parcourue par ce véhicule - # Somme des distances entre chaque paire de nœuds consécutifs + # Calculate total distance traveled by this vehicle + # Sum of distances between each pair of consecutive nodes for a, b in zip(keyed_nodes, keyed_nodes[1:]): computed_distance_total += float(instance.distances.get((a, b), 0.0)) - # Calculer le nombre et le coût des changements de produits - # Les produits sont exportés avec indexation 0-based dans la solution + # Calculate number and cost of product changes + # Products are exported with 0-based indexing in the solution products_only = [p for (p, _c) in v.products] for prev_p, cur_p in zip(products_only, products_only[1:]): if prev_p != cur_p: computed_total_changes += 1 computed_total_switch_cost += float(instance.costs.get((prev_p, cur_p), 0.0)) - # Vérifier la conservation de la masse pour chaque segment (dépôt → stations) - # Un segment commence au chargement d'un dépôt et se termine au prochain dépôt ou au garage + # Verify mass conservation for each segment (depot → stations) + # A segment starts at loading from a depot and ends at the next depot or garage current_segment_load = None # (depot_key, product, qty) current_segment_delivered = 0.0 - # Parcourir chaque nœud visité par le véhicule + # Iterate through each node visited by the vehicle for idx, (node, (p, _cumul)) in enumerate(zip(v.nodes, v.products)): kind = node["kind"] key = solution_node_key(kind, node["id"]) qty = float(node.get("qty", 0)) if kind == "depot": - # Traitement des dépôts : chargement de produit + # Handle depots: product loading if node["id"] not in depot_by_id: - errors.append(f"Véhicule {v.vehicle_id}: dépôt inconnu D{node['id']}") + errors.append(f"Vehicle {v.vehicle_id}: unknown depot D{node['id']}") - # Vérifier que la quantité chargée ne dépasse pas la capacité du camion + # Verify that loaded quantity does not exceed truck capacity if qty > float(camion.capacity) + 1e-6: errors.append( - f"Véhicule {v.vehicle_id}: capacité dépassée au dépôt {key} (chargé={qty}, capacité={camion.capacity})" + f"Vehicle {v.vehicle_id}: capacity exceeded at depot {key} (loaded={qty}, capacity={camion.capacity})" ) - # Accumuler la quantité totale chargée à ce dépôt pour ce produit + # Accumulate total quantity loaded at this depot for this product loads[(key, p)] = loads.get((key, p), 0.0) + qty - # Vérifier la conservation de la masse du segment précédent - # La quantité chargée doit égaler la quantité livrée aux stations + # Verify mass conservation for previous segment + # Loaded quantity must equal quantity delivered at stations """ if current_segment_load is not None: dkey, pp, expected_qty = current_segment_load if abs(current_segment_delivered - expected_qty) > 1e-2: errors.append( - f"Véhicule {v.vehicle_id}: conservation masse segment {dkey} prod {pp} (chargé={expected_qty}, livré={current_segment_delivered})" + f"Vehicle {v.vehicle_id}: mass conservation on segment {dkey} product {pp} (loaded={expected_qty}, delivered={current_segment_delivered})" ) """ - # Commencer un nouveau segment + # Start a new segment current_segment_load = (key, p, qty) current_segment_delivered = 0.0 elif kind == "station": - # Traitement des stations : livraison de produit + # Handle stations: product delivery if node["id"] not in station_by_id: - errors.append(f"Véhicule {v.vehicle_id}: station inconnue S{node['id']}") + errors.append(f"Vehicle {v.vehicle_id}: unknown station S{node['id']}") - # Accumuler les livraisons globales pour vérifier la satisfaction de la demande + # Accumulate global deliveries to verify demand satisfaction deliveries[(key, p)] = deliveries.get((key, p), 0.0) + qty current_segment_delivered += qty else: - # Traitement des garages : doivent être uniquement au début et à la fin + # Handle garages: must only be at start and end if idx != 0 and idx != len(v.nodes) - 1: - errors.append(f"Véhicule {v.vehicle_id}: garage au milieu de la route (position {idx+1})") + errors.append(f"Vehicle {v.vehicle_id}: garage in the middle of route (position {idx+1})") - # Vérifier la conservation de la masse pour le dernier segment - # (segment se terminant au garage sans rechargement) + # Verify mass conservation for the last segment + # (segment ending at garage without reloading) """ if current_segment_load is not None: dkey, pp, expected_qty = current_segment_load if abs(current_segment_delivered - expected_qty) > 1e-2: errors.append( - f"Véhicule {v.vehicle_id}: conservation masse segment {dkey} prod {pp} (chargé={expected_qty}, livré={current_segment_delivered})" + f"Vehicle {v.vehicle_id}: mass conservation on segment {dkey} product {pp} (loaded={expected_qty}, delivered={current_segment_delivered})" ) """ - # Vérifier que toutes les demandes des stations sont satisfaites - # L'instance utilise une indexation 0-based pour les produits dans les demandes + # Verify that all station demands are satisfied + # The instance uses 0-based indexing for products in the demands for st in instance.stations.values(): for p, demand in st.demand.items(): if demand <= 0: continue delivered = deliveries.get((st.id, p), 0.0) if abs(delivered - float(demand)) > 1e-2: - errors.append(f"Demande non satisfaite: {st.id} prod {p} (demande={demand}, livré={delivered})") + errors.append(f"Unsatisfied demand: {st.id} product {p} (demand={demand}, delivered={delivered})") - # Vérifier que les stocks des dépôts ne sont pas dépassés - # L'instance utilise une indexation 0-based pour les produits dans les stocks + # Verify that depot stocks are not exceeded + # The instance uses 0-based indexing for products in the stocks for d in instance.depots.values(): for p, stock in d.stocks.items(): taken = loads.get((d.id, p), 0.0) if taken - float(stock) > 1e-2: - errors.append(f"Stock dépassé: {d.id} prod {p} (stock={stock}, prélevé={taken})") + errors.append(f"Stock exceeded: {d.id} product {p} (stock={stock}, withdrawn={taken})") - # Construire le dictionnaire des métriques recalculées + # Build the dictionary of recalculated metrics computed = { "used_vehicles": len(solution.vehicles), "total_changes": computed_total_changes, @@ -177,23 +177,23 @@ def verify_solution(instance: Instance, solution: ParsedSolutionDat) -> Tuple[Li "distance_total": computed_distance_total, } - # Comparer les métriques du fichier avec celles recalculées - # Utiliser une tolérance pour les valeurs flottantes (arrondis dans le fichier) + # Compare the metrics from the file with those recalculated + # Use a tolerance for float values (rounding in the file) if solution.metrics.get("used_vehicles") != computed["used_vehicles"]: errors.append( - f"Métrique used_vehicles incohérente: fichier={solution.metrics.get('used_vehicles')} calculé={computed['used_vehicles']}" + f"used_vehicles metric inconsistent: file={solution.metrics.get('used_vehicles')} computed={computed['used_vehicles']}" ) if solution.metrics.get("total_changes") != computed["total_changes"]: errors.append( - f"Métrique total_changes incohérente: fichier={solution.metrics.get('total_changes')} calculé={computed['total_changes']}" + f"total_changes metric inconsistent: file={solution.metrics.get('total_changes')} computed={computed['total_changes']}" ) if abs(float(solution.metrics.get("total_switch_cost", 0.0)) - computed["total_switch_cost"]) > 0.2: errors.append( - f"Métrique total_switch_cost incohérente: fichier={solution.metrics.get('total_switch_cost')} calculé={computed['total_switch_cost']:.2f}" + f"total_switch_cost metric inconsistent: file={solution.metrics.get('total_switch_cost')} computed={computed['total_switch_cost']:.2f}" ) if abs(float(solution.metrics.get("distance_total", 0.0)) - computed["distance_total"]) > 0.2: errors.append( - f"Métrique distance_total incohérente: fichier={solution.metrics.get('distance_total')} calculé={computed['distance_total']:.2f}" + f"distance_total metric inconsistent: file={solution.metrics.get('distance_total')} computed={computed['distance_total']:.2f}" ) return errors, computed @@ -209,11 +209,11 @@ def verify_solution(instance: Instance, solution: ParsedSolutionDat) -> Tuple[Li errors, computed = verify_solution(instance, solution) if errors: - print("Erreurs détectées dans la solution :") + print("Errors detected in the solution:") for err in errors: print(f" - {err}") else: - print("La solution est faisable et cohérente.") + print("The solution is feasible and consistent.") - print("Métriques recalculées :", computed) + print("Recalculated metrics:", computed) diff --git a/backup/core/scoring/score_evaluation.py b/backup/core/scoring/score_evaluation.py index 5174510..21bb686 100644 --- a/backup/core/scoring/score_evaluation.py +++ b/backup/core/scoring/score_evaluation.py @@ -1,294 +1,56 @@ import os import zipfile import shutil -import json import logging -from sqlalchemy.orm import Session +from .utils import _discover_category_dirs, _validate_zip_structure, _format_processor_info, _failed_result -from backup.database import models_db as models -from backup.core.model.feasibility import verify_solution -from backup.core.model.utils import parse_instance, parse_solution COEFFS = {"small": 1.0, "medium": 0.5, "large": 0.2} -BIG_M = 100000.0 +BIG_M = 100000.0 NUMBER_OF_INSTANCES_PER_CATEGORY = 50 logger = logging.getLogger(__name__) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) INSTANCES_ROOT = os.path.join(BASE_DIR, "data", "instances") -# Nouvelles fonctions pour gérer les erreurs -def _mark_submission_failed(submission_id: int, reason: str, db: Session): - """ - Erreur fatale (ZIP illisible, dossier Solutions/ absent...) : - score pénalisé pour que is_ready devienne True côté frontend - et que l'équipe voie un message d'erreur plutôt qu'un spinner infini. - """ - try: - sub = db.query(models.Submission).filter(models.Submission.id == submission_id).first() - if sub: - sub.total_weighted_score = BIG_M * 150 - sub.is_fully_feasible = False - sub.total_feasible_count = 0 - sub.category_stats = json.dumps({"small": 0, "medium": 0, "large": 0}) - sub.processor_info = reason - db.commit() - except Exception as e: - logger.exception("[WORKER %s] Unable to mark submission as failed: %s", submission_id, e) - - -def _discover_category_dirs(extract_root: str) -> tuple[dict, list]: - """ - Trouve les dossiers small/medium/large n'importe où dans l'arborescence du ZIP. - Retourne le chemin retenu par catégorie + d'éventuels avertissements. - """ - candidates = {"small": [], "medium": [], "large": []} - - for root, dirs, _ in os.walk(extract_root): - for dirname in dirs: - category = dirname.lower() - if category in candidates: - candidates[category].append(os.path.join(root, dirname)) - - category_dirs = {} - warnings = [] - - for category, paths in candidates.items(): - if not paths: - continue - - # Choix déterministe : dossier le plus proche de la racine, puis ordre alpha. - sorted_paths = sorted(paths, key=lambda p: (p.count(os.sep), p.lower())) - selected = sorted_paths[0] - category_dirs[category] = selected - - if len(sorted_paths) > 1: - rel_selected = os.path.relpath(selected, extract_root) - warnings.append( - f"Plusieurs dossiers pour '{category}' trouvés ; utilisation de '{rel_selected}'." - ) - - return category_dirs, warnings - - -def _parse_solution_filename(filename: str): - """Retourne (category, instance_num) si le nom est reconnu, sinon None.""" - if not filename.lower().endswith(".dat"): - return None - - stem = filename[:-4] - parts = stem.split("_") - if len(parts) < 3 or parts[0].lower() != "sol": - return None - - # Format court : Sol_S_002.dat - if parts[1].upper() in {"S", "M", "L"} and len(parts[2]) == 3 and parts[2].isdigit(): - category = {"S": "small", "M": "medium", "L": "large"}[parts[1].upper()] - return category, parts[2] - - # Format long : Sol_MPVRP_S_002_*.dat (suffixe libre) - if ( - len(parts) >= 4 - and parts[1].lower() == "mpvrp" - and parts[2].upper() in {"S", "M", "L"} - and len(parts[3]) == 3 - and parts[3].isdigit() - ): - category = {"S": "small", "M": "medium", "L": "large"}[parts[2].upper()] - return category, parts[3] - - return None - - -def _index_category_solution_files(cat_path: str, category: str) -> dict: - """Indexe les fichiers .dat d'une catégorie par numéro d'instance reconnu.""" - parsed_candidates = {} - unexpected = [] - dat_files = [f for f in os.listdir(cat_path) if f.lower().endswith(".dat")] - - for filename in dat_files: - parsed = _parse_solution_filename(filename) - if parsed is None: - unexpected.append(filename) - continue - - parsed_category, instance_num = parsed - if parsed_category != category: - unexpected.append(filename) - continue - - instance_idx = int(instance_num) - if instance_idx < 1 or instance_idx > NUMBER_OF_INSTANCES_PER_CATEGORY: - unexpected.append(filename) - continue - - parsed_candidates.setdefault(instance_num, []).append(filename) - - files_by_instance = {} - duplicates = {} - for instance_num, names in parsed_candidates.items(): - sorted_names = sorted(names, key=lambda n: (len(n), n.lower())) - files_by_instance[instance_num] = sorted_names[0] - if len(sorted_names) > 1: - duplicates[instance_num] = sorted_names[1:] - - return { - "dat_count": len(dat_files), - "files_by_instance": files_by_instance, - "unexpected": sorted(unexpected), - "duplicates": duplicates, - } +def process_full_submission(zip_path: str) -> dict: + """Evaluates a ZIP submission and returns a results dictionary. -def _validate_zip_structure(extract_root: str, category_dirs: dict, discovery_warnings=None) -> dict: + :param zip_path: Path to the submitted ZIP file. + :return: Dictionary with score, feasibility, and details per instance. """ - Pré-check de la structure du ZIP après extraction. - Retourne un rapport avec : - - ok : bool — True si tout est conforme - - warnings : list[str] — anomalies non bloquantes - - errors : list[str] — anomalies bloquantes par catégorie - - by_category : dict — état détaillé par catégorie - """ - report = { - "ok": True, - "warnings": list(discovery_warnings or []), - "errors": [], - "by_category": {} - } - - prefix_map = {"small": "S", "medium": "M", "large": "L"} - - for category, prefix in prefix_map.items(): - cat_report = { - "present": False, - "dat_count": 0, - "missing": [], - "unexpected": [], - "duplicates": {}, - "files_by_instance": {}, - } - cat_path = category_dirs.get(category) - - if cat_path is None: - cat_report["present"] = False - report["errors"].append( - f"Dossier '{category}' absent (recherche insensible à la casse) — " - f"les 50 instances {category} recevront la pénalité maximale." - ) - report["ok"] = False - else: - cat_report["present"] = True - category_index = _index_category_solution_files(cat_path, category) - cat_report["dat_count"] = category_index["dat_count"] - cat_report["unexpected"] = category_index["unexpected"] - cat_report["duplicates"] = category_index["duplicates"] - cat_report["files_by_instance"] = category_index["files_by_instance"] - - expected_ids = {f"{i:03d}" for i in range(1, NUMBER_OF_INSTANCES_PER_CATEGORY + 1)} - found_ids = set(category_index["files_by_instance"].keys()) - missing = sorted(expected_ids - found_ids) - cat_report["missing"] = missing - - if ( - category_index["dat_count"] != NUMBER_OF_INSTANCES_PER_CATEGORY - or missing - or category_index["unexpected"] - or category_index["duplicates"] - ): - cat_display = os.path.relpath(cat_path, extract_root) - msg = ( - f"{cat_display} : {category_index['dat_count']} fichier(s) .dat, " - f"{len(found_ids)} instance(s) reconnue(s) sur {NUMBER_OF_INSTANCES_PER_CATEGORY}." - ) - if missing: - msg += f" Manquants : {', '.join(f'Sol_{prefix}_{m}.dat' for m in missing[:5])}" - if len(missing) > 5: - msg += f" … (+{len(missing) - 5})" - if category_index["unexpected"]: - msg += f" Noms non reconnus : {', '.join(category_index['unexpected'][:3])}" - if len(category_index["unexpected"]) > 3: - msg += f" … (+{len(category_index['unexpected']) - 3})" - if category_index["duplicates"]: - dup_count = sum(len(v) for v in category_index["duplicates"].values()) - msg += f" Doublons ignorés : {dup_count}." - report["warnings"].append(msg) - - report["by_category"][category] = cat_report - - return report - - -def _format_processor_info(report: dict) -> str: - """Sérialise le rapport de structure en string lisible pour processor_info.""" - lines = ["=== Rapport de structure du ZIP ==="] - - if report["ok"] and not report["warnings"]: - lines.append("Structure conforme — 3 catégories présentes, 50 fichiers chacune.") - return "\n".join(lines) - - if report["errors"]: - lines.append("\nERREURS BLOQUANTES :") - for e in report["errors"]: - lines.append(f" • {e}") - - if report["warnings"]: - lines.append("\nAVERTISSEMENTS :") - for w in report["warnings"]: - lines.append(f" • {w}") - - lines.append("\n--- Détail par catégorie ---") - for cat, info in report["by_category"].items(): - status = "✅" if info["present"] and info["dat_count"] == NUMBER_OF_INSTANCES_PER_CATEGORY else "❌" - lines.append(f" {status} {cat.capitalize()} : {info['dat_count']}/{NUMBER_OF_INSTANCES_PER_CATEGORY} fichiers") - - return "\n".join(lines) - - -def process_full_submission(submission_id: int, zip_path: str, db: Session): - extract_path = f"temp_extract_{submission_id}" + extract_path = f"temp_extract_{os.path.basename(zip_path)}" total_valid_instances = 0 - results_per_category = {"small": 0, "medium": 0, "large": 0} + results_per_category = {"small": 0, "medium": 0, "large": 0} + instance_results = [] try: - # Vérification existence du ZIP if not os.path.exists(zip_path): - _mark_submission_failed(submission_id, f"Fichier ZIP introuvable : {zip_path}", db) - return + return _failed_result(f"ZIP file not found: {zip_path}") - # Extraction try: with zipfile.ZipFile(zip_path, 'r') as zip_ref: zip_ref.extractall(extract_path) except zipfile.BadZipFile: - _mark_submission_failed(submission_id, "Le fichier soumis n'est pas un ZIP valide.", db) - return + return _failed_result("The submitted file is not a valid ZIP file.") except Exception as e: - _mark_submission_failed(submission_id, f"Erreur lors de l'extraction : {e}", db) - return + return _failed_result(f"Error during extraction: {e}") category_dirs, discovery_warnings = _discover_category_dirs(extract_path) - - # Pré-check structure détaillé structure_report = _validate_zip_structure(extract_path, category_dirs, discovery_warnings) - processor_info = _format_processor_info(structure_report) + processor_info = _format_processor_info(structure_report) - # On met à jour processor_info dès maintenant (avant la boucle) - sub = db.query(models.Submission).filter(models.Submission.id == submission_id).first() - if sub: - sub.processor_info = processor_info - db.commit() - - # Boucle d'évaluation total_weighted_sum = 0 - fully_feasible = True + fully_feasible = True for category, weight in COEFFS.items(): category_score = 0 - instance_dir = os.path.join(INSTANCES_ROOT, category) - cat_info = structure_report["by_category"].get(category, {}) - category_path = category_dirs.get(category) + instance_dir = os.path.join(INSTANCES_ROOT, category) + cat_info = structure_report["by_category"].get(category, {}) + category_path = category_dirs.get(category) files_by_instance = cat_info.get("files_by_instance", {}) instance_mapping = {} @@ -300,32 +62,29 @@ def process_full_submission(submission_id: int, zip_path: str, db: Session): instance_mapping[parts[2]] = f for i in range(1, NUMBER_OF_INSTANCES_PER_CATEGORY + 1): - num_str = f"{i:03d}" - prefix = category[0].upper() - sol_name = f"Sol_{prefix}_{num_str}.dat" - errors = [] - metrics = {} - feasible = False + num_str = f"{i:03d}" + prefix = category[0].upper() + sol_name = f"Sol_{prefix}_{num_str}.dat" + errors = [] + metrics = {} + feasible = False if not cat_info.get("present", False): - # Erreur déjà signalée dans processor_info - errors = [f"Catégorie {category} absente du ZIP (voir rapport de structure)."] - + errors = [f"Category {category} missing from ZIP."] else: selected_solution = files_by_instance.get(num_str) sol_path = os.path.join(str(category_path), selected_solution) if category_path and selected_solution else None inst_filename = instance_mapping.get(num_str) - inst_path = os.path.join(instance_dir, inst_filename) if inst_filename else None + inst_path = os.path.join(instance_dir, inst_filename) if inst_filename else None if not inst_filename: - errors = [f"Instance officielle {num_str} introuvable sur le serveur."] + errors = [f"Official instance {num_str} not found on server."] elif not sol_path or not os.path.exists(sol_path): - errors = [ - f"Aucun fichier solution valide trouvé pour l'instance {num_str} " - f"dans la catégorie '{category}'." - ] + errors = [f"No valid solution file found for instance {num_str}."] else: try: + from backup.core.model.feasibility import verify_solution + from backup.core.model.utils import parse_instance, parse_solution instance_obj = parse_instance(inst_path) solution_obj = parse_solution(str(sol_path)) errors, metrics = verify_solution(instance_obj, solution_obj) @@ -334,7 +93,7 @@ def process_full_submission(submission_id: int, zip_path: str, db: Session): total_valid_instances += 1 results_per_category[category] += 1 except Exception as e: - errors = [f"Erreur technique lors du parsing : {e}"] + errors = [f"Technical error during parsing: {e}"] instance_score = ( metrics.get("distance_total", 0) + metrics.get("total_switch_cost", 0) @@ -344,34 +103,33 @@ def process_full_submission(submission_id: int, zip_path: str, db: Session): fully_feasible = False category_score += instance_score - - db.add(models.InstanceResult( - submission_id = submission_id, - category = category, - instance_name = sol_name, - is_feasible = feasible, - calculated_distance = metrics.get("distance_total", 0), - calculated_transition_cost = metrics.get("total_switch_cost", 0), - errors_log = json.dumps(errors) - )) + instance_results.append({ + "instance": sol_name, + "category": category, + "feasible": feasible, + "distance": metrics.get("distance_total", 0), + "transition_cost": metrics.get("total_switch_cost", 0), + "errors": errors, + }) total_weighted_sum += category_score * weight - #Mise à jour finale - sub = db.query(models.Submission).filter(models.Submission.id == submission_id).first() - if sub: - sub.total_weighted_score = total_weighted_sum / 3 - sub.is_fully_feasible = fully_feasible - sub.total_feasible_count = total_valid_instances - sub.category_stats = json.dumps(results_per_category) - db.commit() + return { + "ok": True, + "total_weighted_score": total_weighted_sum / 3, + "is_fully_feasible": fully_feasible, + "total_feasible_count": total_valid_instances, + "category_stats": results_per_category, + "processor_info": processor_info, + "instance_results": instance_results, + } except Exception as fatal_e: - _mark_submission_failed(submission_id, f"Erreur inattendue : {fatal_e}", db) + return _failed_result(f"Unexpected error: {fatal_e}") finally: if os.path.exists(extract_path): shutil.rmtree(extract_path) - if os.path.exists(zip_path): - os.remove(zip_path) \ No newline at end of file + os.remove(zip_path) + diff --git a/backup/core/scoring/utils.py b/backup/core/scoring/utils.py new file mode 100644 index 0000000..d58a230 --- /dev/null +++ b/backup/core/scoring/utils.py @@ -0,0 +1,232 @@ +import os +import logging + +COEFFS = {"small": 1.0, "medium": 0.5, "large": 0.2} +BIG_M = 100000.0 +NUMBER_OF_INSTANCES_PER_CATEGORY = 50 + +logger = logging.getLogger(__name__) + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +INSTANCES_ROOT = os.path.join(BASE_DIR, "data", "instances") + + +def _discover_category_dirs(extract_root: str) -> tuple[dict, list]: + """ + Trouve les dossiers small/medium/large n'importe où dans l'arborescence du ZIP. + Retourne le chemin retenu par catégorie + d'éventuels avertissements. + """ + candidates = {"small": [], "medium": [], "large": []} + + for root, dirs, _ in os.walk(extract_root): + for dirname in dirs: + category = dirname.lower() + if category in candidates: + candidates[category].append(os.path.join(root, dirname)) + + category_dirs = {} + warnings = [] + + for category, paths in candidates.items(): + if not paths: + continue + + # Choix déterministe : dossier le plus proche de la racine, puis ordre alpha. + sorted_paths = sorted(paths, key=lambda p: (p.count(os.sep), p.lower())) + selected = sorted_paths[0] + category_dirs[category] = selected + + if len(sorted_paths) > 1: + rel_selected = os.path.relpath(selected, extract_root) + warnings.append( + f"Plusieurs dossiers pour '{category}' trouvés ; utilisation de '{rel_selected}'." + ) + + return category_dirs, warnings + + +def _parse_solution_filename(filename: str): + """Retourne (category, instance_num) si le nom est reconnu, sinon None.""" + if not filename.lower().endswith(".dat"): + return None + + stem = filename[:-4] + parts = stem.split("_") + if len(parts) < 3 or parts[0].lower() != "sol": + return None + + # Format court : Sol_S_002.dat + if parts[1].upper() in {"S", "M", "L"} and len(parts[2]) == 3 and parts[2].isdigit(): + category = {"S": "small", "M": "medium", "L": "large"}[parts[1].upper()] + return category, parts[2] + + # Format long : Sol_MPVRP_S_002_*.dat (suffixe libre) + if ( + len(parts) >= 4 + and parts[1].lower() == "mpvrp" + and parts[2].upper() in {"S", "M", "L"} + and len(parts[3]) == 3 + and parts[3].isdigit() + ): + category = {"S": "small", "M": "medium", "L": "large"}[parts[2].upper()] + return category, parts[3] + + return None + + +def _index_category_solution_files(cat_path: str, category: str) -> dict: + """Indexe les fichiers .dat d'une catégorie par numéro d'instance reconnu.""" + parsed_candidates = {} + unexpected = [] + dat_files = [f for f in os.listdir(cat_path) if f.lower().endswith(".dat")] + + for filename in dat_files: + parsed = _parse_solution_filename(filename) + if parsed is None: + unexpected.append(filename) + continue + + parsed_category, instance_num = parsed + if parsed_category != category: + unexpected.append(filename) + continue + + instance_idx = int(instance_num) + if instance_idx < 1 or instance_idx > NUMBER_OF_INSTANCES_PER_CATEGORY: + unexpected.append(filename) + continue + + parsed_candidates.setdefault(instance_num, []).append(filename) + + files_by_instance = {} + duplicates = {} + for instance_num, names in parsed_candidates.items(): + sorted_names = sorted(names, key=lambda n: (len(n), n.lower())) + files_by_instance[instance_num] = sorted_names[0] + if len(sorted_names) > 1: + duplicates[instance_num] = sorted_names[1:] + + return { + "dat_count": len(dat_files), + "files_by_instance": files_by_instance, + "unexpected": sorted(unexpected), + "duplicates": duplicates, + } + + +def _validate_zip_structure(extract_root: str, category_dirs: dict, discovery_warnings=None) -> dict: + """ + Pré-check de la structure du ZIP après extraction. + Retourne un rapport avec : + - ok : bool — True si tout est conforme + - warnings : list[str] — anomalies non bloquantes + - errors : list[str] — anomalies bloquantes par catégorie + - by_category : dict — état détaillé par catégorie + """ + report = { + "ok": True, + "warnings": list(discovery_warnings or []), + "errors": [], + "by_category": {} + } + + prefix_map = {"small": "S", "medium": "M", "large": "L"} + + for category, prefix in prefix_map.items(): + cat_report = { + "present": False, + "dat_count": 0, + "missing": [], + "unexpected": [], + "duplicates": {}, + "files_by_instance": {}, + } + cat_path = category_dirs.get(category) + + if cat_path is None: + cat_report["present"] = False + report["errors"].append( + f"Dossier '{category}' absent (recherche insensible à la casse) — " + f"les 50 instances {category} recevront la pénalité maximale." + ) + report["ok"] = False + else: + cat_report["present"] = True + category_index = _index_category_solution_files(cat_path, category) + cat_report["dat_count"] = category_index["dat_count"] + cat_report["unexpected"] = category_index["unexpected"] + cat_report["duplicates"] = category_index["duplicates"] + cat_report["files_by_instance"] = category_index["files_by_instance"] + + expected_ids = {f"{i:03d}" for i in range(1, NUMBER_OF_INSTANCES_PER_CATEGORY + 1)} + found_ids = set(category_index["files_by_instance"].keys()) + missing = sorted(expected_ids - found_ids) + cat_report["missing"] = missing + + if ( + category_index["dat_count"] != NUMBER_OF_INSTANCES_PER_CATEGORY + or missing + or category_index["unexpected"] + or category_index["duplicates"] + ): + cat_display = os.path.relpath(cat_path, extract_root) + msg = ( + f"{cat_display} : {category_index['dat_count']} fichier(s) .dat, " + f"{len(found_ids)} instance(s) reconnue(s) sur {NUMBER_OF_INSTANCES_PER_CATEGORY}." + ) + if missing: + msg += f" Manquants : {', '.join(f'Sol_{prefix}_{m}.dat' for m in missing[:5])}" + if len(missing) > 5: + msg += f" … (+{len(missing) - 5})" + if category_index["unexpected"]: + msg += f" Noms non reconnus : {', '.join(category_index['unexpected'][:3])}" + if len(category_index["unexpected"]) > 3: + msg += f" … (+{len(category_index['unexpected']) - 3})" + if category_index["duplicates"]: + dup_count = sum(len(v) for v in category_index["duplicates"].values()) + msg += f" Doublons ignorés : {dup_count}." + report["warnings"].append(msg) + + report["by_category"][category] = cat_report + + return report + + +def _format_processor_info(report: dict) -> str: + """Sérialise le rapport de structure en string lisible pour processor_info.""" + lines = ["=== Rapport de structure du ZIP ==="] + + if report["ok"] and not report["warnings"]: + lines.append("Structure conforme — 3 catégories présentes, 50 fichiers chacune.") + return "\n".join(lines) + + if report["errors"]: + lines.append("\nERREURS BLOQUANTES :") + for e in report["errors"]: + lines.append(f" • {e}") + + if report["warnings"]: + lines.append("\nAVERTISSEMENTS :") + for w in report["warnings"]: + lines.append(f" • {w}") + + lines.append("\n--- Détail par catégorie ---") + for cat, info in report["by_category"].items(): + status = "✅" if info["present"] and info["dat_count"] == NUMBER_OF_INSTANCES_PER_CATEGORY else "❌" + lines.append(f" {status} {cat.capitalize()} : {info['dat_count']}/{NUMBER_OF_INSTANCES_PER_CATEGORY} fichiers") + + return "\n".join(lines) + + + +def _failed_result(reason: str) -> dict: + """Retourne un résultat d'échec standardisé.""" + return { + "ok": False, + "total_weighted_score": BIG_M * 150, + "is_fully_feasible": False, + "total_feasible_count": 0, + "category_stats": {"small": 0, "medium": 0, "large": 0}, + "processor_info": reason, + "instance_results": [], + } \ No newline at end of file diff --git a/backup/database/db.py b/backup/database/db.py deleted file mode 100644 index 97aeeb5..0000000 --- a/backup/database/db.py +++ /dev/null @@ -1,47 +0,0 @@ -import os -import logging -from urllib.parse import urlparse - -from dotenv import load_dotenv - -from sqlalchemy import create_engine -from sqlalchemy.orm import declarative_base, sessionmaker - -load_dotenv() - -logger = logging.getLogger(__name__) - - -def _resolve_database_url() -> str: - """Resolve and validate DATABASE_URL with a safe local default.""" - database_url = os.getenv("DATABASE_URL", "sqlite:///./mpvrp_scoring.db") - parsed = urlparse(database_url) - - if not parsed.scheme: - raise RuntimeError("DATABASE_URL must include a valid scheme (e.g., sqlite:///..., postgresql://...).") - - return database_url - -SQLALCHEMY_DATABASE_URL = _resolve_database_url() -is_sqlite = SQLALCHEMY_DATABASE_URL.startswith("sqlite") -engine_kwargs: dict[str, object] = {"pool_pre_ping": True} -if is_sqlite: - engine_kwargs["connect_args"] = {"check_same_thread": False} - logger.info("Using SQLite database at %s", SQLALCHEMY_DATABASE_URL) - -engine = create_engine(SQLALCHEMY_DATABASE_URL, **engine_kwargs) -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base = declarative_base() - - -def init_db() -> None: - """Create database tables based on SQLAlchemy models (call at startup).""" - Base.metadata.create_all(bind=engine) - - -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() \ No newline at end of file diff --git a/backup/database/models.py b/backup/database/models.py new file mode 100644 index 0000000..abcfe3d --- /dev/null +++ b/backup/database/models.py @@ -0,0 +1,34 @@ +from typing import Optional + +from pydantic import BaseModel + + +class InstanceResultSchema(BaseModel): + instance: str + category: str + feasible: bool + distance: float + transition_cost: float + errors: list[str] + + +class SubmissionResultSchema(BaseModel): + submission_id: str + submitted_at: Optional[str] + total_score: float + is_fully_feasible: bool + total_valid_instances: str + total_valid_instances_per_category: Optional[str] + is_ready: bool + processor_info: Optional[str] + instances_details: list[InstanceResultSchema] + + +class LeaderboardEntry(BaseModel): + rank: int + name: str + email: Optional[str] + score: float + feasible_solutions: int + status: Optional[str] + submitted_at: Optional[str] \ No newline at end of file diff --git a/backup/database/models_db.py b/backup/database/models_db.py deleted file mode 100644 index 6a59fa1..0000000 --- a/backup/database/models_db.py +++ /dev/null @@ -1,43 +0,0 @@ -import datetime - -from .db import Base - -from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime -from sqlalchemy.orm import relationship - - -class User(Base): - __tablename__ = "users" - id = Column(Integer, primary_key=True, index=True) - team_name = Column(String, unique=True, index=True) - email = Column(String, unique=True, index=True) - password_hash = Column(String) - submissions = relationship("Submission", back_populates="owner") - -class Submission(Base): - __tablename__ = "submissions" - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id")) - submitted_at = Column(DateTime, default=datetime.datetime.utcnow) - total_weighted_score = Column(Float) - is_fully_feasible = Column(Boolean, default=False) - total_feasible_count = Column(Integer, default = 0) - category_stats = Column(String) - processor_info = Column(String) - resolution_time_seconds = Column(Float) - - owner = relationship("User", back_populates="submissions") - instance_results = relationship("InstanceResult", back_populates="submission") - -class InstanceResult(Base): - __tablename__ = "instance_results" - id = Column(Integer, primary_key=True, index=True) - submission_id = Column(Integer, ForeignKey("submissions.id")) - category = Column(String) # Small, Medium, Large - instance_name = Column(String) # ex: Sol_MPVRP_S_01 - is_feasible = Column(Boolean) - calculated_distance = Column(Float) - calculated_transition_cost = Column(Float) - errors_log = Column(String) # JSON string des erreurs - - submission = relationship("Submission", back_populates="instance_results") \ No newline at end of file diff --git a/backup/database/notion.py b/backup/database/notion.py new file mode 100644 index 0000000..e7d50cf --- /dev/null +++ b/backup/database/notion.py @@ -0,0 +1,355 @@ +import os +import logging +from datetime import datetime, timezone + +from dotenv import load_dotenv +from notion_client import Client + +load_dotenv() + +# Configurer les logs +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s - %(message)s" +) +logger = logging.getLogger(__name__) + +NOTION_TOKEN = os.getenv("NOTION_TOKEN") +DATABASE_ID = os.getenv("NOTION_DATABASE_ID") +DATA_SOURCE_ID = os.getenv("NOTION_DATA_SOURCE_ID") + +# Initialisation du client +notion = Client(auth=NOTION_TOKEN, logger=logger) + + +def list_databases(): + """Récupère la liste des bases de données disponibles dans Notion et leurs identifiants.""" + try: + logger.info("Récupération de la liste des bases de données Notion...") + results = notion.search( + filter={"value": "data_source", "property": "object"} + ).get("results") + + databases = [] + for db in results: + title_list = db.get("title", []) + title = title_list[0].get("plain_text", "Sans titre") if title_list else "Sans titre" + db_id = db.get("id") + databases.append({title: db_id}) + + logger.info(f"{len(databases)} base(s) de données trouvée(s).") + return databases + + except Exception as e: + logger.error(f"Erreur lors de la récupération des bases de données : {e}") + return [] + + +def get_database_fields(database_id: str) -> dict: + """ + Récupère la liste des champs (propriétés) disponibles dans une base de données Notion. + + :param database_id: L'identifiant de la base de données Notion. + :return: Un dictionnaire avec les noms des champs comme clés et leurs types comme valeurs. + """ + logger.info(f"Récupération des champs de la base de données : {database_id}") + + try: + response = notion.databases.retrieve(database_id=database_id) + properties = response.get("properties", {}) + + fields = { + name: prop["type"] + for name, prop in properties.items() + } + + logger.info(f"{len(fields)} champ(s) trouvé(s) : {list(fields.keys())}") + return fields + + except Exception as e: + logger.error(f"Erreur lors de la récupération des champs : {e}") + return {} + + +def get_data_source_fields(data_source_id: str) -> dict: + """Recupère la liste des champs (propriétés) disponibles dans une data source Notion. + + :params data_source_id: L'identifiant de la data source Notion. + :return: Un dictionnaire avec les noms des champs comme clés et leurs types comme valeurs. + """ + logger.info(f"Récupération des champs de la data source...") + try: + response = notion.data_sources.retrieve(data_source_id=data_source_id) + properties = response.get("properties", {}) + + fields = { + name: prop["type"] + for name, prop in properties.items() + } + + logger.info(f"{len(fields)} champ(s) trouvé(s) : {list(fields.keys())}") + return fields + + except Exception as e: + logger.error(f"Erreur lors de la récupération des champs : {e}") + return {} + + +def query_data_source(data_source_id: str, filter: dict = None, sorts: list = None) -> list: + """Récupère les entrées d'une data source Notion avec pagination automatique. + + :param data_source_id: L'identifiant de la data source Notion. + :param filter: Filtre optionnel sur les propriétés. + :param sorts: Tri optionnel sur les propriétés. + :return: Liste de toutes les entrées (pages) de la data source. + """ + logger.info("Récupération des données de la data source...") + + results = [] + has_more = True + next_cursor = None + + try: + while has_more: + params = {"data_source_id": data_source_id} + if filter: + params["filter"] = filter + if sorts: + params["sorts"] = sorts + if next_cursor: + params["start_cursor"] = next_cursor + + response = notion.data_sources.query(**params) + + results.extend(response.get("results", [])) + has_more = response.get("has_more", False) + next_cursor = response.get("next_cursor") + + logger.info(f"{len(results)} entrée(s) récupérée(s) jusqu'ici...") + + logger.info(f"Terminé : {len(results)} entrée(s) au total.") + return results + + except Exception as e: + logger.error(f"Erreur lors de la récupération des données : {e}") + return [] + + +def _extract_value(prop: dict): + """Extrait la valeur brute d'une propriété Notion selon son type.""" + ptype = prop.get("type") + if ptype == "title": + items = prop.get("title", []) + return items[0]["plain_text"] if items else None + elif ptype == "rich_text": + items = prop.get("rich_text", []) + return items[0]["plain_text"] if items else None + elif ptype == "number": + return prop.get("number") + elif ptype == "email": + return prop.get("email") + elif ptype == "select": + sel = prop.get("select") + return sel["name"] if sel else None + elif ptype == "date": + val = prop.get("date") + return val["start"] if val else None + elif ptype in ("last_edited_time", "created_time"): + return prop.get(ptype) + return None + + +def get_all_entries(data_source_id: str) -> list: + """Récupère toutes les entrées de la data source avec pagination. + + :param data_source_id: L'identifiant de la data source Notion. + :return: Liste de toutes les entrées. + """ + logger.info("Récupération de toutes les entrées...") + results = [] + has_more = True + next_cursor = None + + try: + while has_more: + params = {"data_source_id": data_source_id} + if next_cursor: + params["start_cursor"] = next_cursor + + response = notion.data_sources.query(**params) + results.extend(response.get("results", [])) + has_more = response.get("has_more", False) + next_cursor = response.get("next_cursor") + + logger.info(f"{len(results)} entrée(s) récupérée(s).") + return results + + except Exception as e: + logger.error(f"Erreur lors de la récupération des entrées : {e}") + return [] + + +def _compute_rankings(entries: list) -> dict[str, int]: + """Calcule le classement de toutes les entrées. + + Critères : score croissant (bas = meilleur), puis date de soumission croissante. + + :param entries: Liste des entrées Notion. + :return: Dictionnaire {page_id: rank}. + """ + parsed = [] + for entry in entries: + props = entry.get("properties", {}) + score = _extract_value(props.get("Score", {})) + date_str = _extract_value(props.get("Submission Date", {})) + + # Entrées sans score classées en dernier + score = score if score is not None else float("inf") + date = datetime.fromisoformat(date_str) if date_str else datetime.max.replace(tzinfo=timezone.utc) + + parsed.append((entry["id"], score, date)) + + # Tri : score croissant, puis date croissante + parsed.sort(key=lambda x: (x[1], x[2])) + + return {page_id: rank + 1 for rank, (page_id, _, _) in enumerate(parsed)} + + +def upsert_submission( + data_source_id: str, + email: str, + score: float, + feasible_solutions: int, + name: str = None, +) -> str: + """Crée ou met à jour une soumission dans la data source Notion, + puis recalcule le classement de toutes les entrées. + + :param data_source_id: L'identifiant de la data source Notion. + :param email: Adresse e-mail du participant (clé d'identification). + :param score: Score de la solution soumise. + :param feasible_solutions: Nombre de solutions réalisables. + :param name: Nom du participant (requis uniquement à la création). + :return: L'ID de la page Notion créée ou mise à jour. + """ + logger.info(f"Upsert de la soumission pour : {email}") + + # Déterminer le statut selon les solutions réalisables + if feasible_solutions is None: + status = "Null" + elif feasible_solutions < 150: + status = "Draft" + else: + status = "Complete" + + # Propriétés communes (create + update) + properties = { + "Email": {"email": email}, + "Score": {"number": score}, + "Feasible solutions": {"number": feasible_solutions}, + "Submission Status": {"select": {"name": status}}, + } + + try: + existing_entries = get_all_entries(data_source_id) + existing_page = None + + for entry in existing_entries: + entry_email = _extract_value(entry["properties"].get("Email", {})) + if entry_email == email: + existing_page = entry + break + + # Timestamp de soumission — géré manuellement + submission_date = datetime.now(timezone.utc).isoformat() + properties["Submission date"] = { + "date": {"start": submission_date} + } + + if existing_page: + logger.info(f"Entrée existante trouvée ({existing_page['id']}), mise à jour...") + if name is not None: + properties["Name"] = {"rich_text": [{"text": {"content": name}}]} + notion.pages.update(page_id=existing_page["id"], properties=properties) + page_id = existing_page["id"] + + else: + if name is None: + raise ValueError(f"Le nom est requis pour créer une nouvelle soumission (email: {email})") + logger.info("Aucune entrée existante, création d'une nouvelle page...") + properties["Name"] = {"rich_text": [{"text": {"content": name}}]} + database_id = ( + existing_entries[0]["parent"]["database_id"] + if existing_entries + else os.getenv("NOTION_DATABASE_ID") + ) + response = notion.pages.create( + parent={"database_id": database_id}, + properties=properties, + ) + page_id = response["id"] + + # Recalculer les classements — ne mettre à jour que si le rang change + logger.info("Recalcul des classements...") + updated_entries = get_all_entries(data_source_id) + rankings = _compute_rankings(updated_entries) + + for entry in updated_entries: + pid = entry["id"] + new_rank = rankings.get(pid) + old_rank = _extract_value(entry["properties"].get("Rank", {})) + if new_rank != old_rank: + notion.pages.update( + page_id=pid, + properties={"Rank": {"number": new_rank}} + ) + logger.info(f"Rang mis à jour : {pid} → {new_rank}") + + logger.info(f"Classements mis à jour.") + return page_id + + except Exception as e: + logger.error(f"Erreur lors de l'upsert : {e}") + raise + +def delete_submission(data_source_id: str, email: str) -> bool: + """Supprime (archive) une soumission identifiée par l'adresse e-mail, + puis recalcule le classement. + + :param data_source_id: L'identifiant de la data source Notion. + :param email: Adresse e-mail du participant à supprimer. + :return: True si supprimé, False si non trouvé. + """ + logger.info(f"Suppression de la soumission pour : {email}") + + try: + entries = get_all_entries(data_source_id) + target_page_id = None + + for entry in entries: + entry_email = _extract_value(entry["properties"].get("Email", {})) + if entry_email == email: + target_page_id = entry["id"] + break + + if not target_page_id: + logger.warning(f"Aucune soumission trouvée pour : {email}") + return False + + # Archiver la page (suppression logique dans Notion) + notion.pages.update(page_id=target_page_id, archived=True) + logger.info(f"Soumission archivée : {target_page_id}") + + # Recalculer les classements + updated_entries = get_all_entries(data_source_id) + rankings = _compute_rankings(updated_entries) + for pid, rank in rankings.items(): + notion.pages.update(page_id=pid, properties={"Rank": {"number": rank}}) + logger.info(f"Classements recalculés après suppression.") + + return True + + except Exception as e: + logger.error(f"Erreur lors de la suppression : {e}") + return False + diff --git a/generate_config.sh b/generate_config.sh new file mode 100755 index 0000000..fcf2916 --- /dev/null +++ b/generate_config.sh @@ -0,0 +1,5 @@ +#!/bin/bash +source .env +sed "s|__API_URL__|$API_URL|g" \ + pages/static/js/config.template.js > pages/static/js/config.js +echo "config.js généré avec API_URL=$API_URL" \ No newline at end of file diff --git a/index.html b/index.html index a378001..26512e0 100644 --- a/index.html +++ b/index.html @@ -24,12 +24,9 @@
- We provide a suite of tools to assist in model testing, including a validator for solution certification and a synthetic instance generator. For implementation details and endpoint specifications, visit the official - documentation. -
-