Skip to content
Merged
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
29 changes: 29 additions & 0 deletions docs/modules/feed-categories.md
Original file line number Diff line number Diff line change
@@ -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`)
по убыванию, чтобы последовательность страниц была определённой.
1 change: 1 addition & 0 deletions feed/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,5 @@ def to_representation(self, instance):
return {
"type_model": type_model,
"content": content,
"published_at": data["datetime_created"],
}
121 changes: 114 additions & 7 deletions feed/tests/test_feed_api.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -18,17 +23,26 @@ 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")

response = self.client.get("/feed/?type=news")

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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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(
Expand All @@ -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 = {
Expand Down
47 changes: 30 additions & 17 deletions feed/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = {
Expand All @@ -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)
Expand All @@ -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
Loading