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 @@

  • Documentation
  • Instances
  • Visualisation
  • -
  • Outils
  • +
  • Tools
  • Scoreboard
  • Submission
  • - @@ -113,14 +110,6 @@

    Visualisation

    -
    -

    Outils

    -

    - 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. -

    -
    - @@ -134,18 +123,6 @@

    Outils

    document.querySelectorAll('.nav-links a').forEach(link => { link.addEventListener('click', () => navLinks.classList.remove('active')); }); - - // ── Logout conditionnel ─────────────────────────────────── - function logoutFromIndex() { - localStorage.removeItem('token'); - window.location.replace(window.location.pathname); - } - - window.addEventListener('DOMContentLoaded', () => { - if (localStorage.getItem('token')) { - document.getElementById('nav-logout').style.display = 'inline'; - } - }); diff --git a/pages/scoreboard.html b/pages/scoreboard.html index be90096..c56753a 100644 --- a/pages/scoreboard.html +++ b/pages/scoreboard.html @@ -24,10 +24,7 @@

  • Home
  • Scoreboard
  • Submission
  • - - +
  • Tools
  • diff --git a/pages/static/css/submission.css b/pages/static/css/submission.css index f0ef04f..6ddf65f 100644 --- a/pages/static/css/submission.css +++ b/pages/static/css/submission.css @@ -1,11 +1,11 @@ -@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=Gochi+Hand&family=Jost:ital,wght@0,100..900;1,100..900&family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Ubuntu:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=Jost:ital,wght@0,100..900;1,100..900&family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Ubuntu:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap'); .auth-container { - max-width: 460px; + max-width: 760px; margin: 40px auto; padding: 30px; border: 1px solid #ddd; - border-radius: 6px; + border-radius: 10px; background: #fff; } @@ -16,127 +16,56 @@ } .auth-subtitle { - color: #888; + color: #6a7481; font-size: 15px; - margin-bottom: 25px; -} - -.tabs { - display: flex; - border-bottom: 2px solid #eee; - margin-bottom: 25px; -} - -.tabs button { - flex: 1; - padding: 10px; - border: none; - background: none; - cursor: pointer; - font-family: inherit; - font-size: 16px; - color: #888; - transition: color 0.2s; -} - -.tabs button.active { - color: #3C27F5; - border-bottom: 2px solid #3C27F5; - font-weight: bold; - margin-bottom: -2px; + margin-bottom: 18px; } .form-group { margin-bottom: 16px; } +.form-group--left { + text-align: left; +} + .form-group label { display: block; - margin-bottom: 5px; + margin-bottom: 6px; font-weight: bold; font-size: 15px; } .form-group input { width: 100%; - padding: 9px 11px; - border: 1px solid #ccc; - border-radius: 4px; + padding: 10px 11px; + border: 1px solid #cfd5dd; + border-radius: 6px; box-sizing: border-box; font-family: inherit; font-size: 15px; - transition: border-color 0.2s; + transition: border-color 0.2s, box-shadow 0.2s; } .form-group input:focus { outline: none; - border-color: #3C27F5; -} - -/* ── Password Visibility Toggle ─────────────────────── */ -.password-wrapper { - position: relative; - display: flex; - align-items: center; -} - -.password-wrapper input { - padding-right: 40px; + border-color: #3c27f5; + box-shadow: 0 0 0 3px rgba(60, 39, 245, 0.12); } -.toggle-password { - position: absolute; - right: 10px; - background: none; - border: none; - cursor: pointer; - font-size: 18px; - padding: 5px 8px; - color: #666; - transition: color 0.2s; -} - -.toggle-password:hover { - color: #3C27F5; -} - -.toggle-password:active { - transform: scale(0.95); -} - -.btn-auth { - width: 100%; - padding: 10px; - background-color: #3C27F5; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 16px; - font-family: inherit; - margin-top: 5px; - transition: background-color 0.2s, opacity 0.2s; -} - -.btn-auth:hover:not(:disabled) { background-color: #2d1fd4; } -.btn-auth:disabled { opacity: 0.65; cursor: not-allowed; } - -/* ── Espace utilisateur ─────────────────────────────── */ -.user-space { display: none; } - .upload-card { - border: 2px dashed #3C27F5; - padding: 30px; + border: 1px solid #dbd8ff; + padding: 26px; text-align: center; - margin: 25px 0; - border-radius: 8px; - background: #fafafe; + margin: 20px 0; + border-radius: 10px; + background: linear-gradient(180deg, #fafafe 0%, #f7f8ff 100%); } .upload-card h3 { margin-bottom: 10px; color: #1f2d3a; - font-size: 1.1rem; + font-size: 1.08rem; } .upload-card p { @@ -167,7 +96,7 @@ } .upload-card .upload-hint { - margin-bottom: 16px; + margin-bottom: 18px; font-size: 14px; color: #657487; } @@ -183,29 +112,36 @@ .upload-card input[type="file"] { display: block; - margin: 0 auto 15px; + margin: 0 auto 0; font-family: inherit; font-size: 15px; } .btn-upload { - background: #3C27F5; + width: 100%; + margin-top: 4px; + background: #3c27f5; color: #fff; border: none; - border-radius: 4px; - padding: 9px 22px; + border-radius: 6px; + padding: 10px 22px; font-size: 15px; font-family: inherit; cursor: pointer; transition: background-color 0.2s, opacity 0.2s; } -.btn-upload:hover:not(:disabled) { background-color: #2d1fd4; } -.btn-upload:disabled { opacity: 0.65; cursor: not-allowed; } +.btn-upload:hover:not(:disabled) { + background-color: #2d1fd4; +} + +.btn-upload:disabled { + opacity: 0.65; + cursor: not-allowed; +} -/* ── Bannière message ───────────────────────────────── */ .msg-banner { - border-radius: 4px; + border-radius: 6px; padding: 10px 14px; margin-top: 15px; font-size: 15px; @@ -213,74 +149,84 @@ transition: opacity 0.4s; } -/* ── Bouton Détails dans l'historique ───────────────── */ -.btn-details { - background: none; - border: 1px solid #3C27F5; - color: #3C27F5; - cursor: pointer; - padding: 3px 10px; - border-radius: 4px; - font-size: 13px; - font-family: inherit; - transition: background 0.15s, color 0.15s; +.msg-banner--info-static { + margin-top: 12px; + margin-bottom: 14px; + background: #eff6ff; + border: 1px solid #3c27f5; + color: #3c27f5; } -.btn-details:hover:not(:disabled) { - background: #3C27F5; - color: #fff; +.result-section { + margin-top: 26px; } -.btn-details:disabled { opacity: 0.5; cursor: not-allowed; } +.result-card { + background: #fff; + border: 1px solid #e2e7ef; + border-radius: 10px; + padding: 20px; +} -/* ── Logout ─────────────────────────────────────────── */ -.btn-logout { - margin-top: 20px; - background: none; - border: 1px solid #e74c3c; - color: #e74c3c; - cursor: pointer; - padding: 6px 14px; - border-radius: 4px; - font-family: inherit; - font-size: 14px; - transition: background 0.15s, color 0.15s; +.result-card h2 { + margin: 0 0 14px; + font-size: 1.15rem; } -.btn-logout:hover { - background: #e74c3c; - color: #fff; +.result-metrics { + margin-top: 0; } -/* ── Badges Status ──────────────────────────────────── */ -.badge { - display: inline-block; - padding: 2px 10px; - border-radius: 12px; - font-size: 13px; - font-weight: bold; - letter-spacing: 0.02em; +.result-metrics td strong { + color: #2d1fd4; } -.badge-ok { background: #e8f8ee; color: #1e8449; border: 1px solid #a9dfbf; } -.badge-err { background: #fdf2f2; color: #c0392b; border: 1px solid #f5b7b1; } -/* ── Section résultat ───────────────────────────────── */ -#result-section { margin-top: 30px; } +.result-details { + margin-top: 14px; + border: 1px solid #e2e7ef; + border-radius: 8px; + background: #fbfcff; + padding: 0 12px; +} -#result-section details summary { +.result-details[open] { + padding-bottom: 12px; +} + +.result-details summary { cursor: pointer; - color: #3C27F5; - font-weight: bold; - padding: 8px 0; + color: #3c27f5; + font-weight: 600; + padding: 12px 0; user-select: none; } -/* ── Greeting Welcome (Kúabɔ) ──────────────────────── */ -.greeting-welcome { - font-family: 'Gochi Hand', cursive; - color: #000; - font-size: 1.45em; - font-style: normal; - font-weight: 600; - letter-spacing: 0.5px; +.result-details--warning summary { + color: #b03a2e; +} + +.result-pre { + margin: 0; + background: #fdf2f2; + border: 1px solid #f5b7b1; + border-radius: 6px; + padding: 12px; + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +.result-instances { + margin-top: 8px; + font-size: 14px; +} + +.result-row--invalid { + background: #fff6f6; +} + +.result-errors { + color: #c0392b; + font-size: 13px; } diff --git a/pages/static/css/tools.css b/pages/static/css/tools.css new file mode 100644 index 0000000..5b3ab66 --- /dev/null +++ b/pages/static/css/tools.css @@ -0,0 +1,378 @@ +/* Tools Page Styles */ + +.tools-container { + max-width: 900px; + margin: 0 auto; +} + +.tools-subtitle { + color: #6a7481; + font-size: 15px; + margin-bottom: 30px; +} + +/* Tabs Navigation */ +.tabs-nav { + display: flex; + gap: 10px; + border-bottom: 2px solid #ddd; + margin-bottom: 30px; + flex-wrap: wrap; +} + +.tab-btn { + padding: 12px 20px; + background: none; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + font-size: 16px; + color: #666; + font-weight: 500; + transition: all 0.3s ease; + margin-bottom: -2px; +} + +.tab-btn:hover { + color: #3c27f5; +} + +.tab-btn.active { + color: #3c27f5; + border-bottom-color: #3c27f5; +} + +/* Tab Content */ +.tab-content { + display: none; + animation: fadeIn 0.3s ease; +} + +.tab-content.active { + display: block; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Tool Card */ +.tool-card { + border: 1px solid #ddd; + padding: 30px; + border-radius: 10px; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.tool-card h3 { + margin-bottom: 10px; + color: #1f2d3a; + font-size: 1.08rem; + border-bottom: none; + padding-bottom: 0; +} + +.tool-description { + color: #546274; + font-size: 15px; + margin-bottom: 25px; + line-height: 1.6; +} + +/* Forms */ +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 25px; +} + +.form-grid--single { + grid-template-columns: 1fr; +} + +@media (max-width: 768px) { + .form-grid { + grid-template-columns: 1fr; + } +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + font-size: 14px; + color: #1f2d3a; +} + +.form-group input[type="text"], +.form-group input[type="number"], +.form-group input[type="file"] { + padding: 10px 12px; + border: 1px solid #cfd5dd; + border-radius: 6px; + font-family: inherit; + font-size: 15px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-group input[type="text"]:focus, +.form-group input[type="number"]:focus, +.form-group input[type="file"]:focus { + outline: none; + border-color: #3c27f5; + box-shadow: 0 0 0 3px rgba(60, 39, 245, 0.12); +} + +.form-group input[type="file"] { + padding: 8px 12px; + cursor: pointer; +} + +.file-hint { + font-size: 13px; + color: #999; + margin-top: 5px; + margin-bottom: 0; +} + +/* Submit Button */ +.btn-submit { + display: inline-block; + padding: 12px 32px; + background-color: #3c27f5; + color: white; + border: none; + border-radius: 6px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s, box-shadow 0.2s; +} + +.btn-submit:hover { + background-color: #2c1db5; + box-shadow: 0 4px 12px rgba(60, 39, 245, 0.3); +} + +.btn-submit:active { + transform: translateY(1px); +} + +.btn-submit:disabled { + background-color: #ccc; + cursor: not-allowed; + box-shadow: none; +} + +/* Status Messages */ +.status-message { + margin-top: 20px; + padding: 15px 16px; + border-radius: 6px; + font-size: 14px; + border-left: 4px solid; +} + +.status-message.info { + background-color: #e3f2fd; + border-left-color: #2196f3; + color: #1565c0; +} + +.status-message.success { + background-color: #e8f5e9; + border-left-color: #4caf50; + color: #2e7d32; +} + +.status-message.error { + background-color: #ffebee; + border-left-color: #f44336; + color: #c62828; +} + +.status-message.warning { + background-color: #fff3e0; + border-left-color: #ff9800; + color: #e65100; +} + +/* Verification Results */ +.verification-results { + margin-top: 30px; + border: 1px solid #ddd; + border-radius: 10px; + padding: 20px; + background-color: #fafafa; +} + +.results-header { + display: flex; + align-items: center; + margin-bottom: 20px; + gap: 12px; +} + +.results-header h4 { + margin: 0; + font-size: 18px; + color: #1f2d3a; +} + +.result-status-badge { + display: inline-block; + padding: 6px 12px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; +} + +.result-status-badge.feasible { + background-color: #d4edda; + color: #155724; +} + +.result-status-badge.infeasible { + background-color: #f8d7da; + color: #721c24; +} + +/* Errors Section */ +.results-section { + margin-bottom: 20px; +} + +.results-section h5 { + margin: 0 0 12px 0; + font-size: 15px; + color: #333; + font-weight: 600; +} + +.errors-list { + list-style: none; + padding: 0; + margin: 0; +} + +.errors-list li { + padding: 10px 12px; + margin-bottom: 8px; + background-color: #fff; + border-left: 3px solid #f44336; + border-radius: 3px; + font-size: 14px; + color: #333; +} + +/* Metrics Section */ +.metrics-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.metrics-table thead { + background-color: #f5f5f5; +} + +.metrics-table th, +.metrics-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid #ddd; +} + +.metrics-table th { + font-weight: 600; + color: #333; +} + +.metrics-table td { + color: #555; +} + +.metrics-table tbody tr:last-child td { + border-bottom: none; +} + +/* Loading Spinner */ +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid #f3f3f3; + border-top: 2px solid #3c27f5; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* Download Link */ +.download-link { + display: inline-block; + margin-top: 20px; + padding: 10px 16px; + background-color: #e8eaf6; + color: #3c27f5; + text-decoration: none; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + transition: background-color 0.2s; +} + +.download-link:hover { + background-color: #d1d5e8; +} + +/* Responsive */ +@media (max-width: 768px) { + .tool-card { + padding: 20px; + } + + .tabs-nav { + gap: 5px; + } + + .tab-btn { + padding: 10px 16px; + font-size: 14px; + } + + .results-header { + flex-direction: column; + align-items: flex-start; + } + + .metrics-table { + font-size: 13px; + } + + .metrics-table th, + .metrics-table td { + padding: 8px 10px; + } +} + diff --git a/pages/static/js/auth.js b/pages/static/js/auth.js index 1c808f4..0456368 100644 --- a/pages/static/js/auth.js +++ b/pages/static/js/auth.js @@ -1,51 +1,28 @@ -const API_URL = window.APP_CONFIG?.API_URL || "https://mpvrppythonapi.pinite37.me"; -let authMode = 'login'; +const API_URL = window.APP_CONFIG?.API_URL; +const KNOWN_SUBMITTERS_KEY = 'mpvrp_known_submitter_emails'; document.getElementById('mobile-menu').addEventListener('click', () => { document.getElementById('nav-links').classList.toggle('active'); }); -// ═══════════════ -// UTILITAIRES -// ═══════════════ - -function togglePasswordVisibility() { - const passwordInput = document.getElementById('password'); - const toggleBtn = document.getElementById('toggle-password'); - const icon = document.getElementById('password-icon'); - - if (passwordInput.type === 'password') { - passwordInput.type = 'text'; - icon.innerText = '👀'; - } else { - passwordInput.type = 'password'; - icon.innerText = '🙈'; - } -} - function showMessage(message, type = 'error') { const existing = document.getElementById('msg-banner'); if (existing) existing.remove(); const colors = { - error: { bg: '#fdf2f2', border: '#e74c3c', text: '#c0392b' }, + error: { bg: '#fdf2f2', border: '#e74c3c', text: '#c0392b' }, success: { bg: '#f0fdf4', border: '#27ae60', text: '#1e8449' }, - info: { bg: '#eff6ff', border: '#3C27F5', text: '#3C27F5' }, + info: { bg: '#eff6ff', border: '#3C27F5', text: '#3C27F5' }, }; const c = colors[type] || colors.error; const banner = document.createElement('div'); banner.id = 'msg-banner'; banner.className = 'msg-banner'; - banner.style.cssText = ` - background:${c.bg}; border:1px solid ${c.border}; color:${c.text}; - `; + banner.style.cssText = `background:${c.bg}; border:1px solid ${c.border}; color:${c.text};`; banner.innerText = message; - const authVisible = document.getElementById('auth-section').style.display !== 'none'; - const anchor = authVisible - ? document.getElementById('auth-form') - : document.getElementById('user-section').querySelector('.upload-card'); + const anchor = document.querySelector('.upload-card'); anchor.insertAdjacentElement('afterend', banner); if (type !== 'info') { @@ -56,326 +33,140 @@ function showMessage(message, type = 'error') { } } -function setBtn(id, loading, defaultText) { - const btn = document.getElementById(id); - if (!btn) return; - btn.disabled = loading; - btn.innerText = loading ? "Loading..." : defaultText; +function setBtn(loading) { + const btn = document.getElementById('upload-btn'); + btn.disabled = loading; + btn.innerText = loading ? 'Submitting...' : 'Start evaluation'; } -function formatDate(isoStr) { - if (!isoStr) return '—'; - // Format UTC+1 - const normalized = isoStr.replace(' ', 'T').replace(/Z?$/, 'Z'); - const d = new Date(normalized); - if (isNaN(d)) return isoStr; - // Format de date international (en-GB) pour une lecture facile - return d.toLocaleString('en-GB', { timeZone: 'Africa/Porto-Novo' }); +function getKnownSubmitterEmails() { + try { + return JSON.parse(localStorage.getItem(KNOWN_SUBMITTERS_KEY) || '[]'); + } catch (_) { + return []; + } } -// AUTH -function switchTab(mode) { - authMode = mode; - const existing = document.getElementById('msg-banner'); - if (existing) existing.remove(); - - document.getElementById('group-team').style.display = mode === 'register' ? 'block' : 'none'; - document.getElementById('submit-btn').innerText = mode === 'register' ? "Create Account" : "Login"; - document.getElementById('tab-login').classList.toggle('active', mode === 'login'); - document.getElementById('tab-register').classList.toggle('active', mode === 'register'); +function rememberSubmitterEmail(email) { + const normalized = email.trim().toLowerCase(); + if (!normalized) return; + const emails = new Set(getKnownSubmitterEmails()); + emails.add(normalized); + localStorage.setItem(KNOWN_SUBMITTERS_KEY, JSON.stringify(Array.from(emails))); } -async function handleAuth(e) { - e.preventDefault(); - const email = document.getElementById('email').value.trim(); - const password = document.getElementById('password').value; - const label = authMode === 'register' ? "Create Account" : "Login"; - setBtn('submit-btn', true, label); - - if (authMode === 'login') { - const fd = new FormData(); - fd.append('username', email); - fd.append('password', password); - - const res = await fetch(`${API_URL}/auth/login`, { method: 'POST', body: fd }); - if (res.ok) { - const data = await res.json(); - localStorage.setItem('token', data.access_token); - showUserSection(); - } else { - setBtn('submit-btn', false, label); - showMessage("Incorrect email or password.", 'error'); - } - - } else { - const teamName = document.getElementById('team_name').value.trim(); - if (!teamName) { - setBtn('submit-btn', false, label); - return showMessage("Team name is required.", 'error'); - } - const res = await fetch(`${API_URL}/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ team_name: teamName, email, password }) -}); - if (res.ok) { - setBtn('submit-btn', false, label); - showMessage("Registration successful! You can now login.", 'success'); - setTimeout(() => switchTab('login'), 1500); - } else { - const err = await res.json(); - setBtn('submit-btn', false, label); - showMessage(err.detail || "Team name or email already in use.", 'error'); - } - } +function isKnownSubmitter(email) { + const normalized = email.trim().toLowerCase(); + return getKnownSubmitterEmails().includes(normalized); } +async function handleFileUpload(event) { + event.preventDefault(); -// UPLOAD -async function handleFileUpload() { + const nameInput = document.getElementById('name'); + const emailInput = document.getElementById('email'); const fileInput = document.getElementById('zip-file'); - const token = localStorage.getItem('token'); - if (!token) return showMessage("Session expired. Please login again.", 'error'); - if (!fileInput.files[0]) return showMessage("Please choose a .zip file before starting the evaluation.", 'error'); + const name = nameInput.value.trim(); + const email = emailInput.value.trim().toLowerCase(); + const file = fileInput.files[0]; - setBtn('upload-btn', true, "Start Evaluation"); - - const fd = new FormData(); - fd.append('file', fileInput.files[0]); - - const res = await fetch(`${API_URL}/scoring/submit`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - body: fd - }); - - setBtn('upload-btn', false, "Start Evaluation"); - - if (res.ok) { - const data = await res.json(); - showMessage(`Submission accepted — calculation in progress...`, 'info'); - pollResult(data.submission_id); - loadHistory(); - } else if (res.status === 401) { - showMessage("Invalid session. Please login again.", 'error'); - logout(); - } else { - const err = await res.json(); - showMessage("Error: " + (err.detail || "Upload failed."), 'error'); + if (!email) return showMessage('Email is required.', 'error'); + if (!file) return showMessage('Please choose a .zip file before starting the evaluation.', 'error'); + if (!file.name.toLowerCase().endsWith('.zip')) return showMessage('Only .zip files are accepted.', 'error'); + if (!isKnownSubmitter(email) && !name) { + return showMessage('For a first submission, please provide your name and email.', 'error'); } -} -// POLLING -function pollResult(submissionId) { - const token = localStorage.getItem('token'); - const maxRetries = 20; - let attempts = 0; + setBtn(true); - const interval = setInterval(async () => { - attempts++; - try { - const res = await fetch(`${API_URL}/scoring/result/${submissionId}`, { - headers: { 'Authorization': `Bearer ${token}` } - }); + const fd = new FormData(); + fd.append('file', file); + fd.append('email', email); + if (name) fd.append('name', name); - if (res.status === 401) { clearInterval(interval); logout(); return; } + try { + const res = await fetch(`${API_URL}/scoring/submit`, { + method: 'POST', + body: fd, + }); - if (res.ok) { - const data = await res.json(); - console.log(`[POLLING #${attempts}] is_ready=${data.is_ready} score=${data.total_score}`, data); - if (data.is_ready) { - clearInterval(interval); - console.log("[POLLING] Completed — displaying result"); - const b = document.getElementById('msg-banner'); - if (b) b.remove(); - showMessage( - `Score calculated: ${data.total_score.toFixed(4)} — ${data.total_valid_instances} valid instances.`, - 'success' - ); - displayResult(data); - loadHistory(); - } - } - } catch (err) { - console.warn(`[POLLING] Attempt ${attempts} failed:`, err); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || 'Upload failed.'); } - if (attempts >= maxRetries) { - clearInterval(interval); - const b = document.getElementById('msg-banner'); - if (b) b.remove(); - showMessage("Calculation in progress — please check your history in a few moments.", 'info'); - } - }, 3000); + const data = await res.json(); + rememberSubmitterEmail(email); + showMessage(`Submission accepted. Score: ${Number(data.total_score).toFixed(2)}.`, 'success'); + displayResult(data); + } catch (err) { + showMessage(`Error: ${err.message}`, 'error'); + } finally { + setBtn(false); + } } - -// AFFICHAGE RÉSULTAT function displayResult(data) { const existing = document.getElementById('result-section'); if (existing) existing.remove(); const section = document.createElement('div'); - section.id = 'result-section'; + section.id = 'result-section'; + section.className = 'result-section'; section.innerHTML = ` -

    Result — Submission #${data.submission_id}

    - - - - - - - -
    MetricValue
    Weighted Total Score${data.total_score.toFixed(4)}
    Valid Solutions${data.total_valid_instances}
    Full Feasibility${data.is_fully_feasible ? "Yes" : "No"}
    - - ${data.processor_info ? ` -
    - ZIP Structure Report -
    ${data.processor_info}
    -
    ` : ''} +
    +

    Result - Submission #${data.submission_id}

    -
    - Instance Details (${data.instances_details.length}) - +
    - - + - ${data.instances_details.map(r => ` - - - - - - - - - `).join('')} + + +
    InstanceCategoryFeasibleDistanceTransition CostErrorsMetricValue
    ${r.instance}${r.category}${r.feasible ? "✅" : "❌"}${r.distance ?? "—"}${r.transition_cost ?? "—"} - ${r.errors.length > 0 ? r.errors.join('
    ') : "—"} -
    Weighted total score${Number(data.total_score).toFixed(4)}
    Valid solutions${data.total_valid_instances}
    Full feasibility${data.is_fully_feasible ? 'Yes' : 'No'}
    -
    + + ${data.processor_info ? ` +
    + ZIP structure report +
    ${data.processor_info}
    +
    ` : ''} + +
    + Instance details (${data.instances_details.length}) + + + + + + + + + ${data.instances_details.map(r => ` + + + + + + + + + `).join('')} + +
    InstanceCategoryFeasibleDistanceTransition costErrors
    ${r.instance}${r.category}${r.feasible ? 'Yes' : 'No'}${r.distance ?? '—'}${r.transition_cost ?? '—'} + ${r.errors.length > 0 ? r.errors.join('
    ') : '—'} +
    +
    +
    `; const uploadCard = document.querySelector('.upload-card'); uploadCard.insertAdjacentElement('afterend', section); section.scrollIntoView({ behavior: 'smooth', block: 'start' }); } - -// ═══════════════ -// HISTORIQUE -// ═══════════════ - -async function loadHistory() { - const token = localStorage.getItem('token'); - const res = await fetch(`${API_URL}/scoring/history`, { - headers: { 'Authorization': `Bearer ${token}` } - }); - - if (!res.ok) { if (res.status === 401) logout(); return; } - - const data = await res.json(); - document.getElementById('team-name-display').innerText = data.team_name; - - const navTeam = document.getElementById('nav-team-name'); - if (navTeam) navTeam.innerText = `👤 ${data.team_name}`; - - const tbody = document.getElementById('history-body'); - - if (data.history.length === 0) { - tbody.innerHTML = ` - - - No submissions yet. - - `; - return; - } - - tbody.innerHTML = data.history.map(sub => ` - - Submission ${sub.submission_number} - ${formatDate(sub.submitted_at)} - ${sub.score.toFixed(2)} - ${sub.valid_instances} - - ${sub.is_fully_feasible - ? 'Validated' - : 'Incomplete' - } - - - - - - `).join(''); -} - - -// DÉTAILS DEPUIS L'HISTORIQUE -async function loadAndDisplayDetails(submissionId) { - const token = localStorage.getItem('token'); - const btn = document.getElementById(`details-btn-${submissionId}`); - - if (btn) { btn.disabled = true; btn.innerText = "Loading..."; } - - try { - const res = await fetch(`${API_URL}/scoring/result/${submissionId}`, { - headers: { 'Authorization': `Bearer ${token}` } - }); - - if (res.status === 401) { logout(); return; } - if (res.status === 403) { showMessage("Unauthorized access to this submission.", 'error'); return; } - if (!res.ok) { showMessage("Unable to load details.", 'error'); return; } - - const data = await res.json(); - console.log(`[DETAILS #${submissionId}] is_ready=${data.is_ready}`, data); - - if (!data.is_ready) { - showMessage("This submission is still being processed. Please try again in a moment.", 'info'); - return; - } - displayResult(data); - - } catch (err) { - showMessage("Network error while loading details.", 'error'); - console.error(err); - } finally { - if (btn) { btn.disabled = false; btn.innerText = "Details"; } - } -} - - -// SESSION -function showUserSection() { - document.getElementById('auth-section').style.display = 'none'; - document.getElementById('user-section').style.display = 'block'; - document.getElementById('nav-logout-li').style.display = 'inline'; - document.getElementById('nav-team-name').style.display = 'inline'; - loadHistory(); -} - -function logout() { - localStorage.removeItem('token'); - window.location.replace(window.location.pathname); -} - -window.addEventListener('DOMContentLoaded', () => { - if (localStorage.getItem('token')) { - showUserSection(); - } -}); \ No newline at end of file diff --git a/pages/static/js/config.js b/pages/static/js/config.js deleted file mode 100644 index 13536b7..0000000 --- a/pages/static/js/config.js +++ /dev/null @@ -1,3 +0,0 @@ -window.APP_CONFIG = Object.freeze({ - API_URL: "https://mpvrppythonapi.pinite37.me" -}); diff --git a/pages/static/js/config.template.js b/pages/static/js/config.template.js new file mode 100644 index 0000000..f305548 --- /dev/null +++ b/pages/static/js/config.template.js @@ -0,0 +1,3 @@ +window.APP_CONFIG = Object.freeze({ + API_URL: "__API_URL__" +}); \ No newline at end of file diff --git a/pages/static/js/scoreboard.js b/pages/static/js/scoreboard.js index 00b9165..a0fa3b6 100644 --- a/pages/static/js/scoreboard.js +++ b/pages/static/js/scoreboard.js @@ -1,43 +1,15 @@ -const API_URL = window.APP_CONFIG?.API_URL || "https://mpvrppythonapi.pinite37.me"; +const API_URL = window.APP_CONFIG?.API_URL; // ── Nav mobile ─────────────────────────────────────────── document.getElementById('mobile-menu').addEventListener('click', () => { document.getElementById('nav-links').classList.toggle('active'); }); -// ── Session (logout conditionnel) ──────────────────────── +// ── Init ───────────────────────────────────────────────── window.addEventListener('DOMContentLoaded', () => { - if (localStorage.getItem('token')) { - // On récupère le nom d'Team depuis l'historique pour l'afficher dans la nav - fetchTeamName(); - document.getElementById('nav-logout-li').style.display = 'inline'; - } loadLeaderboard(); }); -async function fetchTeamName() { - const token = localStorage.getItem('token'); - if (!token) return; - try { - const res = await fetch(`${API_URL}/scoring/history`, { - headers: { 'Authorization': `Bearer ${token}` } - }); - if (res.ok) { - const data = await res.json(); - const el = document.getElementById('nav-team-name'); - el.innerText = `👤 ${data.team_name}`; - el.style.display = 'inline'; - } else if (res.status === 401) { - logout(); - } - } catch (_) {} -} - -function logout() { - localStorage.removeItem('token'); - window.location.replace(window.location.pathname); -} - // ── Leaderboard ────────────────────────────────────────── async function loadLeaderboard() { const stateEl = document.getElementById('lb-state'); @@ -53,7 +25,7 @@ async function loadLeaderboard() { refreshBtn.disabled = true; try { - const res = await fetch(`${API_URL}/scoreboard/`); + const res = await fetch(`${API_URL}/scoreboard`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); @@ -91,8 +63,24 @@ async function loadLeaderboard() { function formatDate(isoStr) { if (!isoStr) return '—'; - const normalized = isoStr.replace(' ', 'T').replace(/Z?$/, 'Z'); - const d = new Date(normalized); - if (isNaN(d)) return isoStr; - return d.toLocaleString('fr-FR', { timeZone: 'Africa/Porto-Novo' }); + + try { + const d = new Date(isoStr); + if (isNaN(d.getTime())) return isoStr; + + const months = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + const month = months[d.getUTCMonth()]; + const day = d.getUTCDate(); + const year = d.getUTCFullYear(); + + let hours = d.getUTCHours(); + const minutes = String(d.getUTCMinutes()).padStart(2, '0'); + const ampm = hours >= 12 ? 'PM' : 'AM'; + hours = hours % 12 || 12; + + return `${month} ${day}, ${year} ${hours}:${minutes} ${ampm} (UTC)`; + } catch (err) { + return isoStr; + } } \ No newline at end of file diff --git a/pages/static/js/tools.js b/pages/static/js/tools.js new file mode 100644 index 0000000..fc77c1b --- /dev/null +++ b/pages/static/js/tools.js @@ -0,0 +1,288 @@ +// Tab switching functionality +document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', function() { + const tabName = this.getAttribute('data-tab'); + switchTab(tabName); + }); +}); + +function switchTab(tabName) { + // Hide all tabs + document.querySelectorAll('.tab-content').forEach(tab => { + tab.classList.remove('active'); + }); + + // Remove active class from all buttons + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + // Show selected tab + document.getElementById(tabName).classList.add('active'); + + // Add active class to clicked button + document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); +} + +// Mobile menu toggle +const menuToggle = document.getElementById('mobile-menu'); +const navLinks = document.getElementById('nav-links'); + +if (menuToggle) { + menuToggle.addEventListener('click', function() { + navLinks.classList.toggle('active'); + }); + + // Close menu when a link is clicked + navLinks.querySelectorAll('a').forEach(link => { + link.addEventListener('click', function() { + navLinks.classList.remove('active'); + }); + }); +} + +// Generator form submission +async function handleGeneratorSubmit(event) { + event.preventDefault(); + + const btn = document.getElementById('gen-submit-btn'); + const statusDiv = document.getElementById('gen-status'); + + btn.disabled = true; + btn.innerHTML = ' Generating...'; + + // Show loading status + statusDiv.style.display = 'block'; + statusDiv.className = 'status-message info'; + statusDiv.innerHTML = 'Generating instance...'; + + try { + // Collect form data + const formData = { + id_instance: document.getElementById('gen-id').value, + nb_vehicules: parseInt(document.getElementById('gen-vehicles').value), + nb_depots: parseInt(document.getElementById('gen-depots').value), + nb_garages: parseInt(document.getElementById('gen-garages').value), + nb_stations: parseInt(document.getElementById('gen-stations').value), + nb_produits: parseInt(document.getElementById('gen-products').value), + max_coord: parseInt(document.getElementById('gen-maxcoord').value) || null, + min_capacite: parseInt(document.getElementById('gen-mincap').value) || null, + max_capacite: parseInt(document.getElementById('gen-maxcap').value) || null, + min_transition_cost: parseInt(document.getElementById('gen-mintrans').value) || null, + max_transition_cost: parseInt(document.getElementById('gen-maxtrans').value) || null, + min_demand: parseInt(document.getElementById('gen-mindemand').value) || null, + max_demand: parseInt(document.getElementById('gen-maxdemand').value) || null, + seed: document.getElementById('gen-seed').value ? parseInt(document.getElementById('gen-seed').value) : null + }; + + // Remove null values + Object.keys(formData).forEach(key => formData[key] === null && delete formData[key]); + + const response = await fetch(`${window.APP_CONFIG.API_URL}/generator/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(formData) + }); + + if (response.ok) { + // Get filename from response header + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = 'instance.dat'; + if (contentDisposition) { + const match = contentDisposition.match(/filename=([^;]+)/); + if (match && match[1]) { + filename = match[1].replace(/"/g, ''); + } + } + + // Download the file + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + // Show success message + statusDiv.className = 'status-message success'; + statusDiv.innerHTML = `✓ Instance generated successfully! File: ${filename}`; + } else { + const errorData = await response.json(); + statusDiv.className = 'status-message error'; + statusDiv.innerHTML = `✗ Error: ${errorData.detail || 'Generation failed'}`; + } + } catch (error) { + statusDiv.className = 'status-message error'; + statusDiv.innerHTML = `✗ Network error: ${error.message}`; + console.error('Error:', error); + } finally { + btn.disabled = false; + btn.innerHTML = 'Generate Instance'; + } +} + +// Verifier form submission +async function handleVerifierSubmit(event) { + event.preventDefault(); + + const btn = document.getElementById('ver-submit-btn'); + const statusDiv = document.getElementById('ver-status'); + const resultsDiv = document.getElementById('ver-results'); + + btn.disabled = true; + btn.innerHTML = ' Verifying...'; + + // Show loading status + statusDiv.style.display = 'block'; + statusDiv.className = 'status-message info'; + statusDiv.innerHTML = 'Verifying solution...'; + + // Hide previous results + resultsDiv.style.display = 'none'; + + try { + // Create FormData for file upload + const formData = new FormData(); + const instanceFile = document.getElementById('ver-instance').files[0]; + const solutionFile = document.getElementById('ver-solution').files[0]; + + if (!instanceFile || !solutionFile) { + throw new Error('Both files are required'); + } + + formData.append('instance_file', instanceFile); + formData.append('solution_file', solutionFile); + + const response = await fetch(`${window.APP_CONFIG.API_URL}/model/verify`, { + method: 'POST', + body: formData + }); + + if (response.ok) { + const result = await response.json(); + + statusDiv.className = 'status-message success'; + statusDiv.innerHTML = '✓ Verification complete!'; + + displayVerificationResults(result); + } else { + const errorData = await response.json(); + statusDiv.className = 'status-message error'; + statusDiv.innerHTML = `✗ Error: ${errorData.detail || 'Verification failed'}`; + } + } catch (error) { + statusDiv.className = 'status-message error'; + statusDiv.innerHTML = `✗ Error: ${error.message}`; + console.error('Error:', error); + } finally { + btn.disabled = false; + btn.innerHTML = 'Verify Solution'; + } +} + +// Display verification results +function displayVerificationResults(result) { + const resultsDiv = document.getElementById('ver-results'); + resultsDiv.style.display = 'block'; + resultsDiv.innerHTML = ''; + + // Header with feasibility status + const header = document.createElement('div'); + header.className = 'results-header'; + header.innerHTML = ` +

    Verification Results

    + + ${result.feasible ? '✓ FEASIBLE' : '✗ INFEASIBLE'} + + `; + resultsDiv.appendChild(header); + + // Errors section + if (result.errors && result.errors.length > 0) { + const errorSection = document.createElement('div'); + errorSection.className = 'results-section'; + errorSection.innerHTML = '
    Detected Issues
    '; + + const errorsList = document.createElement('ul'); + errorsList.className = 'errors-list'; + result.errors.forEach(error => { + const li = document.createElement('li'); + li.textContent = error; + errorsList.appendChild(li); + }); + + errorSection.appendChild(errorsList); + resultsDiv.appendChild(errorSection); + } else if (result.feasible) { + const noErrorsMsg = document.createElement('div'); + noErrorsMsg.className = 'results-section'; + noErrorsMsg.innerHTML = '

    ✓ No feasibility issues detected.

    '; + resultsDiv.appendChild(noErrorsMsg); + } + + // Metrics section + if (result.metrics) { + const metricsSection = document.createElement('div'); + metricsSection.className = 'results-section'; + metricsSection.innerHTML = '
    Solution Metrics
    '; + + const table = document.createElement('table'); + table.className = 'metrics-table'; + + // Create header + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + const th1 = document.createElement('th'); + th1.textContent = 'Metric'; + const th2 = document.createElement('th'); + th2.textContent = 'Value'; + headerRow.appendChild(th1); + headerRow.appendChild(th2); + thead.appendChild(headerRow); + table.appendChild(thead); + + // Create body + const tbody = document.createElement('tbody'); + for (const [key, value] of Object.entries(result.metrics)) { + const row = document.createElement('tr'); + const td1 = document.createElement('td'); + td1.textContent = formatMetricName(key); + const td2 = document.createElement('td'); + td2.textContent = formatMetricValue(value); + row.appendChild(td1); + row.appendChild(td2); + tbody.appendChild(row); + } + table.appendChild(tbody); + + metricsSection.appendChild(table); + resultsDiv.appendChild(metricsSection); + } +} + +// Format metric names for display +function formatMetricName(name) { + // Convert snake_case to Title Case + return name + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +// Format metric values for display +function formatMetricValue(value) { + if (typeof value === 'number') { + if (Number.isInteger(value)) { + return value.toString(); + } else { + return value.toFixed(2); + } + } + return String(value); +} + diff --git a/pages/submission.html b/pages/submission.html index 9c636b3..da773a4 100644 --- a/pages/submission.html +++ b/pages/submission.html @@ -24,81 +24,46 @@

  • Home
  • Scoreboard
  • Submission
  • - - +
  • Tools
  • +
    +

    Submit your solutions

    +

    Upload one .zip file to evaluate your MPVRP-CC solutions.

    - -
    -

    Team Area

    -

    Log in to submit your solutions and track your score.

    - -
    - - +
    + If this is your first submission, provide your name and email. + If you already submitted before, only your email and .zip file are required.
    -
    - -
    - - -
    -
    - -
    - - -
    -
    - -
    -
    - - -
    - -

    Kúabɔ́,

    -
    -

    Upload your solutions

    -

    Submit one .zip file that includes the three category folders:

    +

    Upload package

    +

    Expected structure inside the ZIP:

    small/ | medium/ | large/

    -

    Each folder should contain your corresponding .dat solution files. You can submit incomplete folders.

    - - -
    +

    Each folder may contain your corresponding .dat files.

    + +
    +
    + + +
    +
    + + +
    -

    Your history

    - - - - - - - - - - - - -
    #DateWeighted scoreValid solutionsStatusActions
    +
    + + +
    - + +
    +
    diff --git a/pages/tools.html b/pages/tools.html new file mode 100644 index 0000000..7b71fd2 --- /dev/null +++ b/pages/tools.html @@ -0,0 +1,162 @@ + + + + + + Tools — MPVRP-CC + + + + + +
    +

    + MPVRP-CC + Tools & Utilities +

    + +
    + +
    +
    +

    Developer Tools

    +

    We provide a suite of tools to assist in model testing, including a validator for solution certification and a synthetic instance generator.

    + + +
    + + +
    + + +
    +
    +

    Generate MPVRP-CC Instance

    +

    + Create a new MPVRP-CC instance with custom parameters. + Modify the parameters below to generate different instance sizes and characteristics. +

    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + +
    +
    + + +
    +
    +

    Verify MPVRP-CC Solution

    +

    + Upload an instance file and its corresponding solution to check feasibility. + The verifier will perform comprehensive validation including capacity constraints, + demand fulfillment, and metric computation. +

    + +
    +
    +
    + + +

    Upload the MPVRP-CC instance file

    +
    +
    + + +

    Upload the solution file

    +
    +
    + + +
    + + + +
    +
    +
    +
    + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 47708ff..9c92b30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "httpx>=0.28.1", "jinja2>=3.1.6", "networkx>=3.6.1", + "notion-client>=3.0.0", "numpy>=2.4.1", "passlib[bcrypt]>=1.7.4", "pulp>=3.3.0", diff --git a/requirements.txt b/requirements.txt index 30e8013..15da25d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ aiofiles>=25.1.0 numpy>=2.4.1 httpx>=0.28.1 sqlalchemy>=2.0.48 +notion-client>=3.0.0 # For auth python-dotenv>=1.1.1 @@ -17,5 +18,5 @@ bcrypt>=4.1.3 python-jose[cryptography]>=3.5.0 # For testing -pytest>=9.0.2 +pytest>=9.1.0 pytest-cov>=4.1.0 diff --git a/start.sh b/start.sh deleted file mode 100755 index 1d5ce44..0000000 --- a/start.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Run from project root so relative paths (like sqlite:///./mpvrp_scoring.db) resolve correctly. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" - -# Production-safe defaults (override with env vars). -HOST="${HOST:-0.0.0.0}" -PORT="${PORT:-8000}" -WORKERS="${WORKERS:-1}" - -# Export DATABASE_URL (uses existing value if already set). -export DATABASE_URL="${DATABASE_URL:-sqlite:///./mpvrp_scoring.db}" -export FRONTEND_PROD_URL="${FRONTEND_PROD_URL:-https://ifri-ai-classes.github.io}" - -# Require stable secret key in environments with external users. -# Generate and export a fresh SECRET_KEY at launch time. -export SECRET_KEY="X2ZlC8ezhVReYCer02s7TdwRT10epQMjwZVKAFwTOE4" -if [[ -z "${SECRET_KEY:-}" ]]; then - echo "ERROR: SECRET_KEY is required. Set it in your environment before starting the server." >&2 - exit 1 -fi - -exec uvicorn backup.app.main:app --host "$HOST" --port "$PORT" --workers "$WORKERS" - diff --git a/tests/conftest.py b/tests/conftest.py index d3143ec..6c4f158 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,6 @@ import sys import tempfile import pytest -import numpy as np # Add the project root to sys.path for imports PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -18,66 +17,19 @@ # ============================================================================ -# DATABASE INITIALIZATION FOR TESTS +# API TEST CLIENT # ============================================================================ -@pytest.fixture(scope="session", autouse=True) -def init_test_database(): - """Initialize test database with proper schema before test session starts.""" - import os - from backup.database.db import engine, Base - from backup.database import models_db as models - - # Use a test-specific database (in-memory or ephemeral) - os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") - - # Create all tables based on SQLAlchemy models - Base.metadata.create_all(bind=engine) - - yield - - # Cleanup: drop all tables after test session - Base.metadata.drop_all(bind=engine) - - -@pytest.fixture -def db_session(): - """Provide a fresh database session for each test that needs it.""" - from backup.database.db import SessionLocal, Base, engine - - # Create tables if not present - Base.metadata.create_all(bind=engine) - - session = SessionLocal() - try: - yield session - finally: - session.rollback() - session.close() - - @pytest.fixture -def client(db_session): - """Provide a TestClient with initialized database for API tests.""" +def client(): + """Provide a TestClient for API tests.""" try: from fastapi.testclient import TestClient from backup.app.main import app - from backup.database.db import get_db - - # Override the get_db dependency to use our test session - app.dependency_overrides[get_db] = lambda: db_session - - test_client = TestClient(app) - yield test_client + with TestClient(app) as test_client: + yield test_client except ImportError: pytest.skip("fastapi testclient not available") - finally: - # Clean up dependency overrides after test - try: - from backup.app.main import app - app.dependency_overrides.clear() - except Exception: - pass # ============================================================================ diff --git a/tests/test_api.py b/tests/test_api.py index 5d8d688..e03db74 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -395,3 +395,60 @@ def test_invalid_json_body(self, client): ) assert response.status_code == 422 + + +class TestScoreboardEndpoint: + """Regression tests for scoreboard route payload normalization.""" + + def test_extract_value_date_returns_iso_string(self): + """Notion date property should be normalized to date.start string.""" + from backup.database.notion import _extract_value + + value = _extract_value({ + "type": "date", + "date": { + "start": "2026-03-30T09:29:24.160+00:00", + "end": None, + "time_zone": None, + }, + }) + + assert value == "2026-03-30T09:29:24.160+00:00" + + def test_scoreboard_handles_notion_date_object(self, client, monkeypatch): + """/scoreboard should return 200 even when Notion date is nested object.""" + import backup.app.routes.scoreboard as scoreboard_route + + monkeypatch.setattr(scoreboard_route, "DATA_SOURCE_ID", "test-data-source") + + def fake_get_all_entries(_data_source_id): + return [{ + "properties": { + "Rank": {"type": "number", "number": 1}, + "Name": {"type": "rich_text", "rich_text": [{"plain_text": "Team A"}]}, + "Score": {"type": "number", "number": 123.456}, + "Feasible solutions": {"type": "number", "number": 150}, + "Submission date": { + "type": "date", + "date": { + "start": "2026-03-30T09:29:24.160+00:00", + "end": None, + "time_zone": None, + }, + }, + } + }] + + monkeypatch.setattr(scoreboard_route, "get_all_entries", fake_get_all_entries) + + response = client.get("/scoreboard") + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["rank"] == 1 + assert data[0]["team"] == "Team A" + assert data[0]["score"] == 123.46 + assert data[0]["instances_validated"] == "150/150" + assert data[0]["last_submission"] == "2026-03-30T09:29:24.160+00:00" + diff --git a/tests/test_notion.py b/tests/test_notion.py new file mode 100644 index 0000000..a94cad8 --- /dev/null +++ b/tests/test_notion.py @@ -0,0 +1,111 @@ +from datetime import datetime, timezone + +import pytest + +from backup.database import notion + + +class _FakePages: + def __init__(self): + self.updated = [] + self.created = [] + + def update(self, **kwargs): + self.updated.append(kwargs) + return {"id": kwargs.get("page_id", "updated")} + + def create(self, **kwargs): + self.created.append(kwargs) + return {"id": "new-page"} + + +class _FakeNotion: + def __init__(self): + self.pages = _FakePages() + + +def test_compute_rankings_orders_by_score_then_submission_date(): + entries = [ + { + "id": "late", + "properties": { + "Score": {"type": "number", "number": 10}, + "Submission Date": {"type": "date", "date": {"start": "2026-03-30T11:00:00+00:00"}}, + }, + }, + { + "id": "early", + "properties": { + "Score": {"type": "number", "number": 10}, + "Submission Date": {"type": "date", "date": {"start": "2026-03-30T10:00:00+00:00"}}, + }, + }, + { + "id": "no-score", + "properties": { + "Score": {"type": "number", "number": None}, + "Submission Date": {"type": "date", "date": {"start": "2026-03-30T09:00:00+00:00"}}, + }, + }, + ] + + rankings = notion._compute_rankings(entries) + + assert rankings["early"] == 1 + assert rankings["late"] == 2 + assert rankings["no-score"] == 3 + + +def test_upsert_submission_updates_existing_email(monkeypatch): + fake_notion = _FakeNotion() + monkeypatch.setattr(notion, "notion", fake_notion) + + existing_entry = { + "id": "page-1", + "properties": { + "Email": {"type": "email", "email": "team@example.com"}, + "Rank": {"type": "number", "number": 2}, + }, + "parent": {"database_id": "db-1"}, + } + + def fake_entries(_data_source_id): + return [existing_entry] + + monkeypatch.setattr(notion, "get_all_entries", fake_entries) + + page_id = notion.upsert_submission( + data_source_id="source-1", + email="team@example.com", + score=100.0, + feasible_solutions=150, + name="Team", + ) + + assert page_id == "page-1" + assert fake_notion.pages.created == [] + assert any(call["page_id"] == "page-1" for call in fake_notion.pages.updated) + + +def test_upsert_submission_requires_name_for_new_entry(monkeypatch): + fake_notion = _FakeNotion() + monkeypatch.setattr(notion, "notion", fake_notion) + monkeypatch.setattr(notion, "get_all_entries", lambda _id: []) + + with pytest.raises(ValueError): + notion.upsert_submission( + data_source_id="source-1", + email="new@example.com", + score=100.0, + feasible_solutions=100, + name=None, + ) + + +def test_extract_value_handles_date_and_created_time(): + date_prop = {"type": "date", "date": {"start": "2026-03-30T09:29:24+00:00"}} + created_prop = {"type": "created_time", "created_time": datetime.now(timezone.utc).isoformat()} + + assert notion._extract_value(date_prop) == "2026-03-30T09:29:24+00:00" + assert notion._extract_value(created_prop) is not None + diff --git a/tests/test_score_evaluation.py b/tests/test_score_evaluation.py new file mode 100644 index 0000000..ae07665 --- /dev/null +++ b/tests/test_score_evaluation.py @@ -0,0 +1,65 @@ +import zipfile + +from backup.core.scoring import score_evaluation +from backup.core.scoring import utils as scoring_utils + + +def test_process_full_submission_returns_failure_for_missing_zip(tmp_path): + missing_zip = tmp_path / "missing.zip" + + result = score_evaluation.process_full_submission(str(missing_zip)) + + assert result["ok"] is False + assert "ZIP file not found" in result["processor_info"] + + +def test_process_full_submission_returns_failure_for_bad_zip_and_cleans_file(tmp_path): + bad_zip = tmp_path / "bad.zip" + bad_zip.write_text("this is not a zip", encoding="utf-8") + + result = score_evaluation.process_full_submission(str(bad_zip)) + + assert result["ok"] is False + assert "not a valid ZIP" in result["processor_info"] + assert not bad_zip.exists() + + +def test_process_full_submission_happy_path_with_minimal_dataset(tmp_path, monkeypatch): + monkeypatch.setattr(score_evaluation, "NUMBER_OF_INSTANCES_PER_CATEGORY", 1) + monkeypatch.setattr(scoring_utils, "NUMBER_OF_INSTANCES_PER_CATEGORY", 1) + + instances_root = tmp_path / "instances" + for category, prefix in (("small", "S"), ("medium", "M"), ("large", "L")): + cat_dir = instances_root / category + cat_dir.mkdir(parents=True) + (cat_dir / f"MPVRP_{prefix}_001_test.dat").write_text("instance", encoding="utf-8") + monkeypatch.setattr(score_evaluation, "INSTANCES_ROOT", str(instances_root)) + + import backup.core.model.feasibility as feasibility + import backup.core.model.utils as model_utils + + monkeypatch.setattr(model_utils, "parse_instance", lambda _path: object()) + monkeypatch.setattr(model_utils, "parse_solution", lambda _path: object()) + monkeypatch.setattr( + feasibility, + "verify_solution", + lambda _i, _s: ([], {"distance_total": 10.0, "total_switch_cost": 5.0}), + ) + + submission_zip = tmp_path / "submission.zip" + with zipfile.ZipFile(submission_zip, "w") as zf: + zf.writestr("small/Sol_S_001.dat", "sol") + zf.writestr("medium/Sol_M_001.dat", "sol") + zf.writestr("large/Sol_L_001.dat", "sol") + + result = score_evaluation.process_full_submission(str(submission_zip)) + + assert result["ok"] is True + assert result["is_fully_feasible"] is True + assert result["total_feasible_count"] == 3 + assert result["category_stats"] == {"small": 1, "medium": 1, "large": 1} + assert len(result["instance_results"]) == 3 + assert result["total_weighted_score"] == 8.5 + assert not submission_zip.exists() + + diff --git a/tests/test_scoring_route.py b/tests/test_scoring_route.py new file mode 100644 index 0000000..f9c228e --- /dev/null +++ b/tests/test_scoring_route.py @@ -0,0 +1,73 @@ +import io +import json + +import pytest +from fastapi.testclient import TestClient + +from backup.app.main import app + + +@pytest.fixture +def scoring_client(): + with TestClient(app) as test_client: + yield test_client + + +def test_scoring_submit_rejects_non_zip(scoring_client): + response = scoring_client.post( + "/scoring/submit", + files={"file": ("solutions.txt", io.BytesIO(b"x"), "text/plain")}, + data={"email": "team@example.com", "name": "Team"}, + ) + + assert response.status_code == 400 + assert "zip" in response.json()["detail"].lower() + + +def test_scoring_submit_returns_formatted_payload(scoring_client, monkeypatch): + import backup.app.routes.scoring as scoring_route + + monkeypatch.setattr(scoring_route, "DATA_SOURCE_ID", None) + monkeypatch.setattr( + scoring_route, + "process_full_submission", + lambda _path: { + "total_weighted_score": 12.345, + "is_fully_feasible": False, + "total_feasible_count": 149, + "category_stats": {"small": 50, "medium": 49, "large": 50}, + "processor_info": "ok", + "instance_results": [ + { + "instance": "Sol_S_001.dat", + "category": "small", + "feasible": True, + "distance": 10.0, + "transition_cost": 1.0, + "errors": [], + } + ], + }, + ) + + response = scoring_client.post( + "/scoring/submit", + files={"file": ("solutions.zip", io.BytesIO(b"zip-bytes"), "application/zip")}, + data={"email": "team@example.com", "name": "Team"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["total_score"] == 12.35 + assert payload["is_fully_feasible"] is False + assert payload["total_valid_instances"] == "149/150" + assert json.loads(payload["total_valid_instances_per_category"]) == { + "small": 50, + "medium": 49, + "large": 50, + } + assert payload["processor_info"] == "ok" + assert payload["is_ready"] is True + assert isinstance(payload["submission_id"], int) + assert "T" in payload["submitted_at"] + diff --git a/tests/test_scoring_utils.py b/tests/test_scoring_utils.py new file mode 100644 index 0000000..085f2bb --- /dev/null +++ b/tests/test_scoring_utils.py @@ -0,0 +1,54 @@ +from backup.core.scoring import utils + + +def test_parse_solution_filename_supports_short_and_long_formats(): + assert utils._parse_solution_filename("Sol_S_002.dat") == ("small", "002") + assert utils._parse_solution_filename("Sol_MPVRP_M_017_anything.dat") == ("medium", "017") + + +def test_parse_solution_filename_rejects_invalid_names(): + assert utils._parse_solution_filename("Sol_X_002.dat") is None + assert utils._parse_solution_filename("Sol_S_2.dat") is None + assert utils._parse_solution_filename("readme.txt") is None + + +def test_discover_category_dirs_prefers_shallow_path_and_warns_duplicates(tmp_path): + (tmp_path / "small").mkdir() + nested = tmp_path / "nested" + nested.mkdir() + (nested / "small").mkdir() + (nested / "medium").mkdir() + + category_dirs, warnings = utils._discover_category_dirs(str(tmp_path)) + + assert category_dirs["small"] == str(tmp_path / "small") + assert category_dirs["medium"] == str(nested / "medium") + assert any("small" in warning for warning in warnings) + + +def test_validate_zip_structure_reports_missing_categories(tmp_path, monkeypatch): + monkeypatch.setattr(utils, "NUMBER_OF_INSTANCES_PER_CATEGORY", 1) + (tmp_path / "small").mkdir() + (tmp_path / "small" / "Sol_S_001.dat").write_text("dummy", encoding="utf-8") + + report = utils._validate_zip_structure( + extract_root=str(tmp_path), + category_dirs={"small": str(tmp_path / "small")}, + ) + + assert report["ok"] is False + assert any("medium" in err for err in report["errors"]) + assert any("large" in err for err in report["errors"]) + assert report["by_category"]["small"]["present"] is True + + +def test_failed_result_returns_expected_shape(): + result = utils._failed_result("boom") + + assert result["ok"] is False + assert result["processor_info"] == "boom" + assert result["total_feasible_count"] == 0 + assert result["instance_results"] == [] + assert set(result["category_stats"].keys()) == {"small", "medium", "large"} + + diff --git a/uv.lock b/uv.lock index 34b41d2..f018949 100644 --- a/uv.lock +++ b/uv.lock @@ -584,6 +584,7 @@ dependencies = [ { name = "httpx" }, { name = "jinja2" }, { name = "networkx" }, + { name = "notion-client" }, { name = "numpy" }, { name = "passlib", extra = ["bcrypt"] }, { name = "pulp" }, @@ -611,6 +612,7 @@ requires-dist = [ { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "networkx", specifier = ">=3.6.1" }, + { name = "notion-client", specifier = ">=3.0.0" }, { name = "numpy", specifier = ">=2.4.1" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "pulp", specifier = ">=3.3.0" }, @@ -634,6 +636,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504 }, ] +[[package]] +name = "notion-client" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/39/60afcbc0148c3dafaaefe851ae3f058077db49d66288dfb218a11a57b997/notion_client-3.0.0.tar.gz", hash = "sha256:05c4d2b4fa3491dc0de21c9c826277202ea8b8714077ee7f51a6e1a09ab23d0f", size = 31357 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/ce/6b03f9aedd2edfcc28e23ced5c2582d543f6ddbb2be5c570533f02890b27/notion_client-3.0.0-py2.py3-none-any.whl", hash = "sha256:177fc3d2ace7e8ef69cf96f46269e8a66071c2c7c526194bf06ce7925853e759", size = 18746 }, +] + [[package]] name = "numpy" version = "2.4.3"