Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion src/ffbb_data_client/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
89 changes: 89 additions & 0 deletions tests/unit/test_314_api_endpoints.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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