diff --git a/docs/modules/feed-categories.md b/docs/modules/feed-categories.md new file mode 100644 index 00000000..0173faa8 --- /dev/null +++ b/docs/modules/feed-categories.md @@ -0,0 +1,29 @@ +# Контракт категорий ленты + +`GET /feed/?type=project|vacancy|news&limit=6&offset=0` сохраняет стандартную +пагинацию `count`, `next`, `previous`, `results` и дополнительно возвращает +`counts` — количество доступных пользователю записей во всей ленте, независимо +от текущего фильтра и страницы: + +```json +{ + "counts": { + "all": 4, + "project": 2, + "vacancy": 1, + "news": 1, + "partnerprogram": 0, + "education": 0 + } +} +``` + +`project` включает новости проектов и записи о новых проектах; `news` — новости +пользователей. Черновики, закрытые проекты и неактивные вакансии не учитываются, +как и в самой ленте. Новости программ и образование пока не включены в этот +endpoint, поэтому их счётчики равны нулю. `all` — сумма доступных категорий. + +Каждый элемент `results` теперь содержит `published_at` — дату публикации +записи ленты. Поле есть и у карточек новостей, и у вакансий, и у новых проектов. +При одинаковой дате сортировка дополнительно использует первичный ключ (`pk`) +по убыванию, чтобы последовательность страниц была определённой. diff --git a/feed/serializers.py b/feed/serializers.py index 4efead6f..b7b48059 100644 --- a/feed/serializers.py +++ b/feed/serializers.py @@ -83,4 +83,5 @@ def to_representation(self, instance): return { "type_model": type_model, "content": content, + "published_at": data["datetime_created"], } diff --git a/feed/tests/test_feed_api.py b/feed/tests/test_feed_api.py index 0ab0e664..fdc3f866 100644 --- a/feed/tests/test_feed_api.py +++ b/feed/tests/test_feed_api.py @@ -1,9 +1,14 @@ +from django.contrib.contenttypes.models import ContentType from django.test import TestCase +from django.utils import timezone +from django.utils.dateparse import parse_datetime from rest_framework.test import APIClient from core.services import set_like from feed.services import create_news_for_model from feed.tests.helpers import create_vacancy +from news.models import News +from news.services import FEED_RECORD_TEXT from news.tests.helpers import ( create_news_for, create_partner_program, @@ -18,6 +23,14 @@ def setUp(self): self.user = create_user(prefix="feed-user") self.client.force_authenticate(self.user) + @staticmethod + def get_feed_record(obj): + return News.objects.get( + content_type=ContentType.objects.get_for_model(obj), + object_id=obj.pk, + text=FEED_RECORD_TEXT, + ) + def test_feed_returns_user_news_when_news_filter_requested(self): news = create_news_for(self.user, text="User feed news") @@ -25,10 +38,11 @@ def test_feed_returns_user_news_when_news_filter_requested(self): self.assertEqual(response.status_code, 200) item = response.data["results"][0] - self.assertEqual(set(item.keys()), {"type_model", "content"}) + self.assertEqual(set(item.keys()), {"type_model", "content", "published_at"}) self.assertEqual(item["type_model"], "news") self.assertEqual(item["content"]["id"], news.id) self.assertEqual(item["content"]["text"], "User feed news") + self.assertEqual(item["published_at"], item["content"]["datetime_created"]) def test_feed_returns_project_news_as_news_content(self): project = create_project(name="Feed project") @@ -38,10 +52,11 @@ def test_feed_returns_project_news_as_news_content(self): self.assertEqual(response.status_code, 200) item = response.data["results"][0] - self.assertEqual(set(item.keys()), {"type_model", "content"}) + self.assertEqual(set(item.keys()), {"type_model", "content", "published_at"}) self.assertEqual(item["type_model"], "news") self.assertEqual(item["content"]["id"], news.id) self.assertEqual(item["content"]["text"], "Project feed news") + self.assertEqual(parse_datetime(item["published_at"]), news.datetime_created) def test_feed_ignores_program_news_filter(self): program = create_partner_program(name="Feed program") @@ -61,9 +76,13 @@ def test_feed_returns_project_feed_record_as_project_content(self): self.assertEqual(response.status_code, 200) item = response.data["results"][0] - self.assertEqual(set(item.keys()), {"type_model", "content"}) + self.assertEqual(set(item.keys()), {"type_model", "content", "published_at"}) self.assertEqual(item["type_model"], "project") self.assertEqual(item["content"]["id"], project.id) + self.assertEqual( + parse_datetime(item["published_at"]), + self.get_feed_record(project).datetime_created, + ) def test_feed_returns_vacancy_feed_record_as_vacancy_content(self): vacancy = create_vacancy(role="Backend developer") @@ -72,10 +91,100 @@ def test_feed_returns_vacancy_feed_record_as_vacancy_content(self): self.assertEqual(response.status_code, 200) item = response.data["results"][0] - self.assertEqual(set(item.keys()), {"type_model", "content"}) + self.assertEqual(set(item.keys()), {"type_model", "content", "published_at"}) self.assertEqual(item["type_model"], "vacancy") self.assertEqual(item["content"]["id"], vacancy.id) self.assertEqual(item["content"]["role"], "Backend developer") + self.assertEqual( + parse_datetime(item["published_at"]), + self.get_feed_record(vacancy).datetime_created, + ) + + def test_category_counts_are_global_and_respect_visibility(self): + project = create_project(name="Visible project") + create_news_for(project, text="Visible project news") + visible_vacancies = { + create_vacancy(project=project, role="First visible vacancy").pk, + create_vacancy(project=project, role="Second visible vacancy").pk, + } + create_news_for(self.user, text="Visible user news") + create_news_for( + create_project(name="Private project", is_public=False), + text="Private project news", + ) + create_news_for( + create_project(name="Draft project", draft=True), + text="Draft project news", + ) + inactive_vacancy = create_vacancy( + project=project, + role="Inactive vacancy", + is_active=False, + ) + create_news_for_model(inactive_vacancy) + create_news_for(create_partner_program(name="Program"), text="Program news") + + first_page = self.client.get("/feed/?type=vacancy&limit=1&offset=0") + second_page = self.client.get("/feed/?type=vacancy&limit=1&offset=1") + + expected_counts = { + "all": 5, + "project": 2, + "vacancy": 2, + "news": 1, + "partnerprogram": 0, + "education": 0, + } + for response in (first_page, second_page): + self.assertEqual(response.status_code, 200) + self.assertEqual( + set(response.data), + {"count", "next", "previous", "results", "counts"}, + ) + self.assertEqual(response.data["count"], 2) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["counts"], expected_counts) + self.assertEqual( + { + item["content"]["id"] + for response in (first_page, second_page) + for item in response.data["results"] + }, + visible_vacancies, + ) + + def test_feed_uses_pk_as_tie_breaker_across_pages(self): + news_items = [ + create_news_for(self.user, text=f"Same time {index}") for index in range(3) + ] + News.objects.filter(pk__in=[news.pk for news in news_items]).update( + datetime_created=timezone.now() + ) + + first_page = self.client.get("/feed/?type=news&limit=2&offset=0") + second_page = self.client.get("/feed/?type=news&limit=2&offset=2") + + self.assertEqual(first_page.status_code, 200) + self.assertEqual(second_page.status_code, 200) + self.assertEqual( + set(first_page.data), + { + "count", + "next", + "previous", + "results", + "counts", + }, + ) + ordered_ids = [ + item["content"]["id"] + for response in (first_page, second_page) + for item in response.data["results"] + ] + self.assertEqual( + ordered_ids, + sorted((news.pk for news in news_items), reverse=True), + ) def test_feed_combines_supported_filters_and_ignores_program_news(self): project_news = create_news_for( @@ -91,9 +200,7 @@ def test_feed_combines_supported_filters_and_ignores_program_news(self): vacancy = create_vacancy(role="Combined vacancy") create_news_for_model(project) - response = self.client.get( - "/feed/?type=project|vacancy|news|partnerprogram" - ) + response = self.client.get("/feed/?type=project|vacancy|news|partnerprogram") self.assertEqual(response.status_code, 200) items_by_text = { diff --git a/feed/views.py b/feed/views.py index a02f628a..afcca516 100644 --- a/feed/views.py +++ b/feed/views.py @@ -1,4 +1,4 @@ -from django.db.models import Q, QuerySet +from django.db.models import Count, Q, QuerySet from rest_framework.views import APIView from feed.pagination import FeedPagination @@ -21,20 +21,11 @@ def _get_filter_data(self) -> list[str]: news_types: list[str] = filter_queries.split("|") if "news" in news_types: news_types.append("customuser") - return [ - news_type - for news_type in news_types - if news_type != "partnerprogram" - ] + return [news_type for news_type in news_types if news_type != "partnerprogram"] - def get_queryset(self) -> QuerySet[News]: - filters = self._get_filter_data() - - queryset = ( - News.objects.select_related("content_type") - .prefetch_related("content_object", "files") - .filter(content_type__model__in=filters) - .order_by("-datetime_created") + def get_visible_queryset(self) -> QuerySet[News]: + queryset = News.objects.select_related("content_type").prefetch_related( + "content_object", "files" ) existing_object_filters = { @@ -49,12 +40,32 @@ def get_queryset(self) -> QuerySet[News]: } for model_name, ids_queryset in existing_object_filters.items(): queryset = queryset.exclude( - Q(content_type__model=model_name) - & ~Q(object_id__in=ids_queryset) + Q(content_type__model=model_name) & ~Q(object_id__in=ids_queryset) ) return queryset + def get_queryset(self) -> QuerySet[News]: + return ( + self.get_visible_queryset() + .filter(content_type__model__in=self._get_filter_data()) + .order_by("-datetime_created", "-pk") + ) + + def get_category_counts(self) -> dict[str, int]: + # Программы и образование пока не включены в ленту; не считаем скрытый контент. + counts = self.get_visible_queryset().aggregate( + project=Count("pk", filter=Q(content_type__model="project")), + vacancy=Count("pk", filter=Q(content_type__model="vacancy")), + news=Count("pk", filter=Q(content_type__model="customuser")), + ) + return { + "all": sum(counts.values()), + **counts, + "partnerprogram": 0, + "education": 0, + } + def get(self, *args, **kwargs): paginator = self.pagination_class() paginated_data = paginator.paginate_queryset(self.get_queryset(), self.request) @@ -66,4 +77,6 @@ def get(self, *args, **kwargs): }, many=True, ) - return paginator.get_paginated_response(serializer.data) + response = paginator.get_paginated_response(serializer.data) + response.data["counts"] = self.get_category_counts() + return response