From de8c33eeb0b1d17d2e8a7f27a144b9ef8d17e25c Mon Sep 17 00:00:00 2001 From: Fab1 <1923887+Fab1@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:22:49 +0200 Subject: [PATCH] feat(api): add date_from/date_to filter to club matches endpoint GET /api/v1/club/{organisme_id}/matches accepts date_from and date_to (YYYY-MM-DD, inclusive). Matches outside the range are skipped using the poule's known date before fetching their detailed rencontre, then the detailed date is re-checked before pagination. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + src/ffbb_data_client/api.py | 45 +++++++++++++- tests/unit/test_314_api_endpoints.py | 89 ++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cdaf997..c1295ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Features +- **Filtre par période du calendrier club** : `GET /api/v1/club/{organisme_id}/matches` accepte `date_from` et `date_to` (AAAA-MM-JJ, bornes incluses). Les rencontres hors période sont écartées avant le chargement de leurs fiches détaillées, ce qui accélère fortement les requêtes sur une semaine. + ## [2.4.39] - 2026-10-14 ### Features & Discovery diff --git a/src/ffbb_data_client/api.py b/src/ffbb_data_client/api.py index 1758f3e4..b0fef6f1 100644 --- a/src/ffbb_data_client/api.py +++ b/src/ffbb_data_client/api.py @@ -15,7 +15,7 @@ import time from collections import OrderedDict from contextlib import asynccontextmanager -from datetime import datetime, timezone +from datetime import date, datetime, timezone from pathlib import Path from typing import Any @@ -954,11 +954,36 @@ async def get_club_matches( description="Décalage de pagination", examples=[0], ), + date_from: date | None = Query( + None, + description="Ne garder que les rencontres à partir de cette date incluse (AAAA-MM-JJ)", + examples=["2026-10-12"], + ), + date_to: date | None = Query( + None, + description="Ne garder que les rencontres jusqu'à cette date incluse (AAAA-MM-JJ)", + examples=["2026-10-18"], + ), ): """ Récupère l'ensemble des rencontres officielles FFBB d'un club, avec détection domicile/extérieur, calcul des équipes, salles résolues avec adresse exacte et logos officiels. + + `date_from` / `date_to` restreignent la période (bornes incluses) avant la pagination. """ + if date_from and date_to and date_from > date_to: + raise HTTPException( + status_code=400, + detail="date_from doit être antérieure ou égale à date_to.", + ) + + def in_date_range(d: date) -> bool: + return (date_from is None or d >= date_from) and ( + date_to is None or d <= date_to + ) + + has_date_filter = date_from is not None or date_to is not None + client = get_client() try: @@ -1083,6 +1108,18 @@ async def fetch_comp_poule_name(pid: int, cid: int): if not is_club1 and not is_club2: continue + # Pré-filtre sur la date connue de la poule pour éviter de charger le + # détail des rencontres hors période (1970 = date absente, revérifiée + # sur la fiche détaillée en fin de traitement). + poule_date = getattr(m, "date_rencontre", None) + if ( + has_date_filter + and isinstance(poule_date, datetime) + and poule_date.year > 1970 + and not in_date_range(poule_date.date()) + ): + continue + seen_match_ids.add(m_id) candidate_matches.append( (m_id, comp_nom, str(pid), poule_nom or "", is_club1, is_club2) @@ -1252,6 +1289,12 @@ async def build_match_card(item): *(build_match_card(item) for item in detailed_items if item) ) matches_list = [m for m in built_matches if m is not None] + if has_date_filter: + matches_list = [ + m + for m in matches_list + if m["dateISO"] and in_date_range(date.fromisoformat(m["dateISO"])) + ] matches_list.sort(key=lambda x: (x.get("dateISO", ""), x.get("time", ""))) response.headers["Cache-Control"] = "public, max-age=180, stale-while-revalidate=60" diff --git a/tests/unit/test_314_api_endpoints.py b/tests/unit/test_314_api_endpoints.py index ddd2d59d..59017fbe 100644 --- a/tests/unit/test_314_api_endpoints.py +++ b/tests/unit/test_314_api_endpoints.py @@ -1,5 +1,7 @@ """Tests for the REST endpoints, Best Practices, error envelopes, and caching in api.py.""" +from datetime import datetime +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -142,3 +144,90 @@ async def test_salle_and_competition_endpoints_caching(): assert r_saisons.status_code == 200 assert "Cache-Control" in r_saisons.headers assert len(r_saisons.json()) == 1 + + +@pytest.mark.asyncio +async def test_club_matches_date_range_filter(): + transport = ASGITransport(app=app) + mock_client = MagicMock() + mock_client.get_organisme_async = AsyncMock( + return_value=SimpleNamespace( + nom="CLUB TEST", + logo=None, + engagements=[ + SimpleNamespace( + idPoule=SimpleNamespace(id="111"), + idCompetition=SimpleNamespace(nom="DM2", id="5"), + ) + ], + ) + ) + # Dates connues par la poule ; 1970 = date absente côté poule + poule_dates = { + "1": datetime(2026, 10, 10, 18, 0), + "2": datetime(2026, 10, 17, 18, 0), + "3": datetime(2026, 10, 25, 18, 0), + "4": datetime(1970, 1, 1), + } + mock_client.get_poule_async = AsyncMock( + return_value=SimpleNamespace( + nom="Poule A", + rencontres=[ + SimpleNamespace( + id=m_id, + nomEquipe1="CLUB TEST", + nomEquipe2="ADVERSAIRE", + idOrganismeEquipe1="42", + idOrganismeEquipe2="99", + date_rencontre=d, + ) + for m_id, d in poule_dates.items() + ], + ) + ) + detail_dates = {"2": "2026-10-17T18:00:00", "4": "2026-10-14T20:30:00"} + mock_client.get_rencontre_async = AsyncMock( + side_effect=lambda m_id: SimpleNamespace( + id=m_id, + nomEquipe1="CLUB TEST", + nomEquipe2="ADVERSAIRE", + idOrganismeEquipe1=None, + idOrganismeEquipe2=None, + date_rencontre=detail_dates.get(m_id, "2026-11-30T18:00:00"), + salle=None, + ) + ) + + with ( + patch("ffbb_data_client.api.get_client", return_value=mock_client), + patch( + "ffbb_data_client.api.resolve_exact_salle_address_async", + AsyncMock(return_value="Gymnase Test"), + ), + ): + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.get( + "/api/v1/club/42/matches?date_from=2026-10-12&date_to=2026-10-18" + ) + assert r.status_code == 200 + data = r.json() + assert [m["ffbbMatchId"] for m in data["matches"]] == ["4", "2"] + assert data["total"] == 2 + + # Les rencontres hors période selon la poule ne sont pas chargées en détail + fetched = {c.args[0] for c in mock_client.get_rencontre_async.await_args_list} + assert fetched == {"2", "4"} + + +@pytest.mark.asyncio +async def test_club_matches_date_range_validation(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.get( + "/api/v1/club/42/matches?date_from=2026-10-18&date_to=2026-10-12" + ) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "BAD_REQUEST" + + r = await ac.get("/api/v1/club/42/matches?date_from=2026-13-01") + assert r.status_code == 422