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` — дату публикации
записи ленты. Поле есть и у карточек новостей, и у вакансий, и у новых проектов.
При одинаковой дате сортировка дополнительно использует `id` по убыванию,
чтобы последовательность страниц была определённой.
7 changes: 5 additions & 2 deletions docs/modules/project-activity-count.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 проекта при наличии нескольких связей с программами.
Expand Down
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"],
}
40 changes: 33 additions & 7 deletions feed/tests/test_feed_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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)

Expand All @@ -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"),
Expand All @@ -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 = {
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
7 changes: 1 addition & 6 deletions projects/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 17 additions & 1 deletion projects/tests/test_project_count.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading