API route to fetch news dates

This commit is contained in:
imperosol 2025-02-17 13:29:12 +01:00
parent fc3b82c35c
commit 86c2ea7fd9
4 changed files with 165 additions and 7 deletions

View File

@ -7,3 +7,17 @@ class ClubSchema(ModelSchema):
class Meta: class Meta:
model = Club model = Club
fields = ["id", "name"] 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()

View File

@ -1,11 +1,16 @@
from pathlib import Path from pathlib import Path
from typing import Literal
from django.conf import settings from django.conf import settings
from django.http import Http404 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.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.auth.api_permissions import HasPerm
from core.views.files import send_raw_file from core.views.files import send_raw_file
@ -37,7 +42,7 @@ class CalendarController(ControllerBase):
@api_controller("/news") @api_controller("/news")
class NewsController(ControllerBase): class NewsController(ControllerBase):
@route.patch( @route.patch(
"/{news_id}/moderate", "/{int:news_id}/moderate",
permissions=[HasPerm("com.moderate_news")], permissions=[HasPerm("com.moderate_news")],
url_name="moderate_news", url_name="moderate_news",
) )
@ -49,10 +54,27 @@ class NewsController(ControllerBase):
news.save() news.save()
@route.delete( @route.delete(
"/{news_id}", "/{int:news_id}",
permissions=[HasPerm("com.delete_news")], permissions=[HasPerm("com.delete_news")],
url_name="delete_news", url_name="delete_news",
) )
def delete_news(self, news_id: int): def delete_news(self, news_id: int):
news = self.get_object_or_exception(News, id=news_id) news = self.get_object_or_exception(News, id=news_id)
news.delete() 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")
)

58
com/schemas.py Normal file
View File

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

View File

@ -3,18 +3,22 @@ from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from urllib.parse import quote
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.http import HttpResponse from django.http import HttpResponse
from django.test.client import Client from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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.calendar import IcsCalendar
from com.models import News from com.models import News, NewsDate
from core.markdown import markdown
from core.models import User from core.models import User
@ -184,3 +188,63 @@ class TestDeleteNews:
) )
assert response.status_code == 403 assert response.status_code == 403
assert News.objects.filter(id=news.id).exists() 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]]