From 2453344e875d9809dbd11a68c3b94464adf1dc91 Mon Sep 17 00:00:00 2001 From: Fedor Date: Wed, 23 Sep 2026 15:16:46 +0300 Subject: [PATCH] fix(feed): correct project activity and expose feed metadata --- docs/modules/feed-categories.md | 29 ++++++++++++++++ docs/modules/project-activity-count.md | 7 ++-- feed/serializers.py | 1 + feed/tests/test_feed_api.py | 40 ++++++++++++++++++---- feed/views.py | 47 ++++++++++++++++---------- projects/managers.py | 7 +--- projects/tests/test_project_count.py | 18 +++++++++- 7 files changed, 116 insertions(+), 33 deletions(-) create mode 100644 docs/modules/feed-categories.md diff --git a/docs/modules/feed-categories.md b/docs/modules/feed-categories.md new file mode 100644 index 00000000..0c031298 --- /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` — дату публикации +записи ленты. Поле есть и у карточек новостей, и у вакансий, и у новых проектов. +При одинаковой дате сортировка дополнительно использует `id` по убыванию, +чтобы последовательность страниц была определённой. diff --git a/docs/modules/project-activity-count.md b/docs/modules/project-activity-count.md index 19586093..1ed790b9 100644 --- a/docs/modules/project-activity-count.md +++ b/docs/modules/project-activity-count.md @@ -17,12 +17,15 @@ - `my` — число уникальных legacy `Project`, где пользователь является лидером или участником `Collaborator`. - `my_leader` — число проектов, где пользователь указан в `Project.leader`. -- `my_in_program` — число опубликованных проектов пользователя, у которых - каноническая связь с программой существует и ещё не сдана. +- `my_in_program` — число проектов пользователя с канонической связью с + программой, включая черновики и уже сданные проекты. - `my_submitted` — число проектов пользователя, у которых каноническая связь помечена как сданная. Сданный проект учитывается независимо от `draft`, поскольку состояние «Сдано» имеет приоритет над «Черновиком». +`my_submitted` всегда не больше `my_in_program`: сданные проекты составляют +подмножество проектов, привязанных к программе. + Канонической считается запись `PartnerProgramProject` с минимальным `pk` для проекта. Это совпадает с правилом, которое используется legacy-карточками и detail проекта при наличии нескольких связей с программами. 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..412c2c25 100644 --- a/feed/tests/test_feed_api.py +++ b/feed/tests/test_feed_api.py @@ -25,10 +25,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,7 +39,7 @@ 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") @@ -61,7 +62,7 @@ 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) @@ -72,11 +73,38 @@ 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") + def test_category_counts_are_global_and_respect_visibility(self): + project = create_project(name="Visible project") + create_news_for(project, text="Visible project news") + create_vacancy(project=project, role="Visible vacancy") + 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_partner_program(name="Program"), text="Program news") + + response = self.client.get("/feed/?type=vacancy&limit=1") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual( + response.data["counts"], + { + "all": 4, + "project": 2, + "vacancy": 1, + "news": 1, + "partnerprogram": 0, + "education": 0, + }, + ) + def test_feed_combines_supported_filters_and_ignores_program_news(self): project_news = create_news_for( create_project(name="Combined project news"), @@ -91,9 +119,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 diff --git a/projects/managers.py b/projects/managers.py index 878a4992..bab5b21a 100644 --- a/projects/managers.py +++ b/projects/managers.py @@ -83,12 +83,7 @@ def get_user_activity_counts(self, user): my_leader=Count("pk", filter=Q(leader_id=user.id), distinct=True), my_in_program=Count( "pk", - filter=( - user_projects - & Q(canonical_program_link_id__isnull=False) - & Q(canonical_program_link_submitted=False) - & Q(draft=False) - ), + filter=user_projects & Q(canonical_program_link_id__isnull=False), distinct=True, ), my_submitted=Count( diff --git a/projects/tests/test_project_count.py b/projects/tests/test_project_count.py index 4c974cf9..c6f8a696 100644 --- a/projects/tests/test_project_count.py +++ b/projects/tests/test_project_count.py @@ -80,8 +80,24 @@ def test_counts_project_lifecycle_by_canonical_program_link(self): self.assertEqual(count["my"], 8) self.assertEqual(count["my_leader"], 8) - self.assertEqual(count["my_in_program"], 2) + self.assertEqual(count["my_in_program"], 6) self.assertEqual(count["my_submitted"], 3) + self.assertLessEqual(count["my_submitted"], count["my_in_program"]) + + def test_program_count_includes_submitted_and_draft_collaborator_projects(self): + program = create_partner_program(name="Collaborator lifecycle") + collaborator_project = create_project(draft=True) + create_collaborator(collaborator_project, user=self.user) + link_project_to_program(collaborator_project, program, submitted=True) + + other_user_project = create_project(draft=False) + link_project_to_program(other_user_project, program, submitted=True) + + count = self.get_count() + + self.assertEqual(count["my"], 1) + self.assertEqual(count["my_in_program"], 1) + self.assertEqual(count["my_submitted"], 1) def test_ignores_other_users_projects_and_counts_more_than_dashboard_page(self): for index in range(17):