From 86c2ea7fd960ec4844d5e5bd923123425a4ebf74 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 17 Feb 2025 13:29:12 +0100 Subject: [PATCH] API route to fetch news dates --- club/schemas.py | 14 +++++++++ com/api.py | 30 ++++++++++++++++--- com/schemas.py | 58 +++++++++++++++++++++++++++++++++++ com/tests/test_api.py | 70 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 com/schemas.py diff --git a/club/schemas.py b/club/schemas.py index cbd35988..7969f119 100644 --- a/club/schemas.py +++ b/club/schemas.py @@ -7,3 +7,17 @@ class ClubSchema(ModelSchema): class Meta: model = Club fields = ["id", "name"] + + +class ClubProfileSchema(ModelSchema): + """The infos needed to display a simple club profile.""" + + class Meta: + model = Club + fields = ["id", "name", "logo"] + + url: str + + @staticmethod + def resolve_url(obj: Club) -> str: + return obj.get_absolute_url() diff --git a/com/api.py b/com/api.py index 4a4d0e17..5a3eef10 100644 --- a/com/api.py +++ b/com/api.py @@ -1,11 +1,16 @@ from pathlib import Path +from typing import Literal from django.conf import settings from django.http import Http404 -from ninja_extra import ControllerBase, api_controller, route +from ninja import Query +from ninja_extra import ControllerBase, api_controller, paginate, route +from ninja_extra.pagination import PageNumberPaginationExtra +from ninja_extra.schemas import PaginatedResponseSchema from com.calendar import IcsCalendar -from com.models import News +from com.models import News, NewsDate +from com.schemas import NewsDateFilterSchema, NewsDateSchema from core.auth.api_permissions import HasPerm from core.views.files import send_raw_file @@ -37,7 +42,7 @@ class CalendarController(ControllerBase): @api_controller("/news") class NewsController(ControllerBase): @route.patch( - "/{news_id}/moderate", + "/{int:news_id}/moderate", permissions=[HasPerm("com.moderate_news")], url_name="moderate_news", ) @@ -49,10 +54,27 @@ class NewsController(ControllerBase): news.save() @route.delete( - "/{news_id}", + "/{int:news_id}", permissions=[HasPerm("com.delete_news")], url_name="delete_news", ) def delete_news(self, news_id: int): news = self.get_object_or_exception(News, id=news_id) news.delete() + + @route.get( + "/date", + url_name="fetch_news_dates", + response=PaginatedResponseSchema[NewsDateSchema], + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def fetch_news_dates( + self, + filters: Query[NewsDateFilterSchema], + text_format: Literal["md", "html"] = "md", + ): + return filters.filter( + NewsDate.objects.viewable_by(self.context.request.user) + .order_by("start_date") + .select_related("news", "news__club") + ) diff --git a/com/schemas.py b/com/schemas.py new file mode 100644 index 00000000..967076ad --- /dev/null +++ b/com/schemas.py @@ -0,0 +1,58 @@ +from datetime import datetime + +from ninja import FilterSchema, ModelSchema +from ninja_extra import service_resolver +from ninja_extra.controllers import RouteContext +from pydantic import Field + +from club.schemas import ClubProfileSchema +from com.models import News, NewsDate +from core.markdown import markdown + + +class NewsDateFilterSchema(FilterSchema): + before: datetime | None = Field(None, q="end_date__lt") + after: datetime | None = Field(None, q="start_date__gt") + club_id: int | None = Field(None, q="news__club_id") + news_id: int | None = None + is_moderated: bool | None = Field(None, q="news__is_moderated") + title: str | None = Field(None, q="news__title__icontains") + + +class NewsSchema(ModelSchema): + class Meta: + model = News + fields = ["id", "title", "summary", "is_moderated"] + + club: ClubProfileSchema + url: str + + @staticmethod + def resolve_summary(obj: News) -> str: + # if this is returned from a route that allows the + # user to choose the text format (md or html) + # and the user chose "html", convert the markdown to html + context: RouteContext = service_resolver(RouteContext) + if context.kwargs.get("text_format", "") == "html": + return markdown(obj.summary) + return obj.summary + + @staticmethod + def resolve_url(obj: News) -> str: + return obj.get_absolute_url() + + +class NewsDateSchema(ModelSchema): + """Basic infos about an event occurrence. + + Warning: + This uses [NewsSchema][], which itself + uses [ClubProfileSchema][club.schemas.ClubProfileSchema]. + Don't forget the appropriated `select_related`. + """ + + class Meta: + model = NewsDate + fields = ["id", "start_date", "end_date"] + + news: NewsSchema diff --git a/com/tests/test_api.py b/com/tests/test_api.py index da1419da..257ee056 100644 --- a/com/tests/test_api.py +++ b/com/tests/test_api.py @@ -3,18 +3,22 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Callable from unittest.mock import MagicMock, patch +from urllib.parse import quote import pytest from django.conf import settings from django.contrib.auth.models import Permission from django.http import HttpResponse -from django.test.client import Client +from django.test import Client, TestCase from django.urls import reverse from django.utils import timezone -from model_bakery import baker +from django.utils.timezone import now +from model_bakery import baker, seq +from pytest_django.asserts import assertNumQueries from com.calendar import IcsCalendar -from com.models import News +from com.models import News, NewsDate +from core.markdown import markdown from core.models import User @@ -184,3 +188,63 @@ class TestDeleteNews: ) assert response.status_code == 403 assert News.objects.filter(id=news.id).exists() + + +class TestFetchNewsDates(TestCase): + @classmethod + def setUpTestData(cls): + News.objects.all().delete() + cls.dates = baker.make( + NewsDate, + _quantity=5, + _bulk_create=True, + start_date=seq(value=now(), increment_by=timedelta(days=1)), + end_date=seq( + value=now() + timedelta(hours=2), increment_by=timedelta(days=1) + ), + news=iter( + baker.make(News, is_moderated=True, _quantity=5, _bulk_create=True) + ), + ) + cls.dates.append( + baker.make( + NewsDate, + start_date=now() + timedelta(days=2, hours=1), + end_date=now() + timedelta(days=2, hours=5), + news=baker.make(News, is_moderated=True), + ) + ) + cls.dates.sort(key=lambda d: d.start_date) + + def test_num_queries(self): + with assertNumQueries(2): + self.client.get(reverse("api:fetch_news_dates")) + + def test_html_format(self): + """Test that when the summary is asked in html, the summary is in html.""" + summary_1 = "# First event\nThere is something happening.\n" + self.dates[0].news.summary = summary_1 + self.dates[0].news.save() + summary_2 = ( + "# Second event\n" + "There is something happening **for real**.\n" + "Everything is [here](https://youtu.be/dQw4w9WgXcQ)\n" + ) + self.dates[1].news.summary = summary_2 + self.dates[1].news.save() + response = self.client.get( + reverse("api:fetch_news_dates") + "?page_size=2&text_format=html" + ) + assert response.status_code == 200 + dates = response.json()["results"] + assert dates[0]["news"]["summary"] == markdown(summary_1) + assert dates[1]["news"]["summary"] == markdown(summary_2) + + def test_fetch(self): + after = quote((now() + timedelta(days=1)).isoformat()) + response = self.client.get( + reverse("api:fetch_news_dates") + f"?page_size=3&after={after}" + ) + assert response.status_code == 200 + dates = response.json()["results"] + assert [d["id"] for d in dates] == [d.id for d in self.dates[1:4]]