diff --git a/docs/react-dev-demo-data.md b/docs/react-dev-demo-data.md index cf571c66..deb50a7e 100644 --- a/docs/react-dev-demo-data.md +++ b/docs/react-dev-demo-data.md @@ -1,4 +1,4 @@ - + # Тестовые данные React-dev @@ -21,6 +21,23 @@ React-dev backend. - решение с частично заполненным draft; - решение с submitted Evaluation и completed assignment. +### Новостная лента DEV-083 + +Тот же запуск создаёт отдельный публичный проект +`[DEMO] Новостная лента React-dev` под руководством первого DEMO-участника и +связанный набор для проверки React-ленты: + +- 11 публичных новостей программы; +- 11 публичных новостей DEMO-проекта; +- 11 публичных новостей, распределённых между DEMO-пользователями; +- одну новость программы с аудиторией `program_participants`; +- публикации с лайками, просмотрами и комментариями, а также публикации без реакций; +- 21 хронологический комментарий у одной новости для проверки второй страницы. + +Во всех трёх публичных вкладках `/feed/news/` есть вторая страница при стандартном +лимите 10. Внутренняя новость отсутствует в списке, открывается по возвращённому +командой ID у участника DEMO-программы и отвечает `404` постороннему пользователю. + ## Меры безопасности Команда завершается до любой записи, если не выполнено хотя бы одно условие: @@ -37,6 +54,12 @@ Reset находит программу только по одновременн это не позволяет каскадному удалению затронуть их возможные внешние связи. Обычные пользователи и программы с похожими названиями не удаляются. +Проект DEV-083 определяется точным служебным именем и ожидаемым DEMO-руководителем. +Если это имя уже занято проектом другого руководителя, транзакция завершается без +изменений. Новости удаляются только по сочетанию ожидаемого источника и полного +точного текста; общий префикс `[DEMO]` не является признаком владения. Поэтому +`--reset` не удаляет другие новости DEMO-пользователей и проекты с похожими именами. + ## Обычный запуск В окружении изолированного React-dev backend включить setting, задать пароль и @@ -55,6 +78,20 @@ python manage.py seed_react_dev_demo --confirm-react-dev --dry-run ``` Команда показывает план и итоговые количества, затем откатывает транзакцию. +В итог также входят количества проектов, публичных и внутренних новостей, лайков, +просмотров и комментариев, а также ID внутренней новости. Пароль не выводится. + +Пример части вывода DEV-083: + +```text + проекты: 1 + публичные новости: 33 + внутренние новости: 1 + лайки: 6 + просмотры: 9 + комментарии: 23 + ID внутренней новости: +``` ## Reset diff --git a/docs/react-news-feed-api.md b/docs/react-news-feed-api.md index e17ffd06..c29f9491 100644 --- a/docs/react-news-feed-api.md +++ b/docs/react-news-feed-api.md @@ -190,7 +190,13 @@ staff/superuser. `news_id` входит в lookup комментария, поэ - создание публикаций из общей ленты не добавлено: программы продолжают публиковать через context endpoint; - UI ленты, popup, deep-link recovery и копирование ссылки входят в DEV-083.2; -- DEMO-новости, лайки и комментарии входят в DEV-083.3. +- DEMO-набор DEV-083.3 создаётся существующей защищённой командой + `python manage.py seed_react_dev_demo --confirm-react-dev`. Он содержит по 11 + публичных новостей программы, проекта и пользователей, реакции и 21 комментарий + у одной публикации. Отдельная новость `program_participants` предназначена для + проверки detail-доступа участника и безопасного `404` для постороннего пользователя. + Точные правила повторного запуска, `--dry-run` и `--reset` описаны в + `docs/react-dev-demo-data.md`. Angular-аудит подтвердил: карточка копирует отдельную ссылку, project/profile detail открывает новость в модальном маршруте, а блок комментариев в карточке diff --git a/partner_programs/management/commands/seed_react_dev_demo.py b/partner_programs/management/commands/seed_react_dev_demo.py index f9a19493..3bf1b397 100644 --- a/partner_programs/management/commands/seed_react_dev_demo.py +++ b/partner_programs/management/commands/seed_react_dev_demo.py @@ -1,4 +1,4 @@ -# Roadmap: DEV-072 +# Roadmap: DEV-072, DEV-083 # Безопасная точка запуска демонстрационного набора только для React-dev. import os @@ -75,6 +75,13 @@ def handle(self, *args, **options): "assignments": "назначения", "evaluations": "оценки", "scores": "баллы", + "projects": "проекты", + "public_news": "публичные новости", + "internal_news": "внутренние новости", + "likes": "лайки", + "views": "просмотры", + "comments": "комментарии", + "internal_news_id": "ID внутренней новости", } self.stdout.write("Результат:") for label, count in summary.as_dict().items(): diff --git a/partner_programs/services/react_dev_demo.py b/partner_programs/services/react_dev_demo.py index a0f15852..56c668bc 100644 --- a/partner_programs/services/react_dev_demo.py +++ b/partner_programs/services/react_dev_demo.py @@ -1,4 +1,4 @@ -# Roadmap: DEV-072 +# Roadmap: DEV-072, DEV-083 # Повторяемый связанный набор данных для ручной проверки React-dev. from dataclasses import dataclass @@ -10,6 +10,8 @@ from django.db.models import Q from django.utils import timezone +from core.models import Like, View +from news.models import NewsComment from partner_programs.models import ( Application, Evaluation, @@ -29,6 +31,11 @@ submit_evaluation, update_draft_evaluation, ) +from partner_programs.services.react_dev_news_demo import ( + ReactDevNewsDemoDataError, + delete_react_dev_news_demo_data, + ensure_react_dev_news_demo_data, +) from partner_programs.services.submission_assignments import ( create_submission_assignment, ) @@ -153,6 +160,13 @@ class ReactDevDemoSummary: assignments: int evaluations: int scores: int + projects: int + public_news: int + internal_news: int + likes: int + views: int + comments: int + internal_news_id: int def as_dict(self): return { @@ -167,6 +181,13 @@ def as_dict(self): "assignments": self.assignments, "evaluations": self.evaluations, "scores": self.scores, + "projects": self.projects, + "public_news": self.public_news, + "internal_news": self.internal_news, + "likes": self.likes, + "views": self.views, + "comments": self.comments, + "internal_news_id": self.internal_news_id, } @@ -529,7 +550,7 @@ def _ensure_evaluation_states(submissions, criteria, expert_user, manager): ) -def _summary(program): +def _summary(program, news_demo): application_qs = Application.objects.filter(program=program) submission_qs = Submission.objects.filter(program=program) evaluation_qs = Evaluation.objects.filter(submission__program=program) @@ -556,6 +577,21 @@ def _summary(program): scores=EvaluationScore.objects.filter( evaluation__submission__program=program ).count(), + projects=1, + public_news=news_demo.public_news_count, + internal_news=1, + likes=Like.objects.filter( + content_type__app_label="news", + content_type__model="news", + object_id__in=news_demo.all_news_ids, + ).count(), + views=View.objects.filter( + content_type__app_label="news", + content_type__model="news", + object_id__in=news_demo.all_news_ids, + ).count(), + comments=NewsComment.objects.filter(news_id__in=news_demo.all_news_ids).count(), + internal_news_id=news_demo.internal_news.pk, ) @@ -564,6 +600,17 @@ def build_react_dev_demo_data(*, password, reset=False, dry_run=False): with transaction.atomic(): if reset: + existing_users = { + spec["key"]: User.objects.filter(email=spec["email"]).first() + for spec in DEMO_USER_SPECS + } + try: + delete_react_dev_news_demo_data( + program=_find_owned_program(), + users_by_key=existing_users, + ) + except ReactDevNewsDemoDataError as exc: + raise ReactDevDemoDataError(str(exc)) from exc _delete_owned_program() users = {spec["key"]: _ensure_user(spec, password) for spec in DEMO_USER_SPECS} @@ -584,7 +631,14 @@ def build_react_dev_demo_data(*, password, reset=False, dry_run=False): users["expert"], users["manager"], ) - summary = _summary(program) + try: + news_demo = ensure_react_dev_news_demo_data( + program=program, + users_by_key=users, + ) + except ReactDevNewsDemoDataError as exc: + raise ReactDevDemoDataError(str(exc)) from exc + summary = _summary(program, news_demo) if dry_run: transaction.set_rollback(True) diff --git a/partner_programs/services/react_dev_news_demo.py b/partner_programs/services/react_dev_news_demo.py new file mode 100644 index 00000000..8b96728c --- /dev/null +++ b/partner_programs/services/react_dev_news_demo.py @@ -0,0 +1,275 @@ +# Roadmap: DEV-083.3 +"""Повторяемый набор новостей для ручной проверки React-dev.""" + +from dataclasses import dataclass + +from django.contrib.contenttypes.models import ContentType +from django.db.models import QuerySet +from django.utils import timezone + +from core.models import Like, View +from news.models import News, NewsComment +from projects.models import Project + + +DEMO_NEWS_PROJECT_NAME = "[DEMO] Новостная лента React-dev" +DEMO_NEWS_PROJECT_DESCRIPTION = ( + "Публичный демонстрационный проект для проверки новостной ленты React-dev." +) +DEMO_INTERNAL_PROGRAM_NEWS_TEXT = ( + "[DEMO][DEV-083] Внутренняя новость для участников программы" +) +DEMO_PROGRAM_NEWS_TEXTS = tuple( + f"[DEMO][DEV-083] Новость программы {str(index).zfill(2)}: этап демонстрации" + for index in range(1, 12) +) +DEMO_PROJECT_NEWS_TEXTS = tuple( + f"[DEMO][DEV-083] Новость проекта {str(index).zfill(2)}: развитие решения" + for index in range(1, 12) +) +DEMO_USER_NEWS_TEXTS = tuple( + f"[DEMO][DEV-083] Новость пользователя {str(index).zfill(2)}: заметка участника" + for index in range(1, 12) +) +DEMO_PAGINATED_COMMENT_TEXTS = tuple( + f"[DEMO][DEV-083] Комментарий к новости {str(index).zfill(2)}" + for index in range(1, 22) +) +DEMO_EXTRA_COMMENT_TEXTS = ( + "[DEMO][DEV-083] Комментарий к новости проекта", + "[DEMO][DEV-083] Комментарий к новости пользователя", +) + + +class ReactDevNewsDemoDataError(Exception): + """Набор новостей нельзя безопасно создать или обновить.""" + + +@dataclass(frozen=True) +class ReactDevNewsDemoResult: + project: Project + program_news: tuple[News, ...] + project_news: tuple[News, ...] + user_news: tuple[News, ...] + internal_news: News + + @property + def all_news_ids(self) -> tuple[int, ...]: + return tuple( + news.pk + for news in ( + *self.program_news, + *self.project_news, + *self.user_news, + self.internal_news, + ) + ) + + @property + def public_news_count(self) -> int: + return len(self.program_news) + len(self.project_news) + len(self.user_news) + + +def _find_owned_project(*, expected_leader) -> Project | None: + candidates = list( + Project.objects.filter(name=DEMO_NEWS_PROJECT_NAME) + .select_related("leader") + .order_by("pk") + ) + if not candidates: + return None + if len(candidates) != 1: + raise ReactDevNewsDemoDataError( + "Точное имя DEMO-проекта новостной ленты соответствует нескольким " + "проектам; требуется ручная проверка." + ) + + project = candidates[0] + if expected_leader is None or project.leader_id != expected_leader.pk: + raise ReactDevNewsDemoDataError( + "Точное имя DEMO-проекта новостной ленты уже занято посторонним проектом." + ) + return project + + +def _news_for_source(source, texts: tuple[str, ...]) -> QuerySet[News]: + content_type = ContentType.objects.get_for_model(source) + return News.objects.filter( + content_type=content_type, + object_id=source.pk, + text__in=texts, + ) + + +def delete_react_dev_news_demo_data(*, program, users_by_key) -> None: + """Удалить только записи с точными идентификаторами набора DEV-083.""" + + expected_leader = users_by_key.get("participant1") + project = _find_owned_project(expected_leader=expected_leader) + + # Тексты и ожидаемый источник вместе образуют идентификатор записи seed-набора: + # общий префикс [DEMO] намеренно не используется для удаления. + if program is not None: + _news_for_source( + program, + (*DEMO_PROGRAM_NEWS_TEXTS, DEMO_INTERNAL_PROGRAM_NEWS_TEXT), + ).delete() + users = tuple(users_by_key.values()) + for index, text in enumerate(DEMO_USER_NEWS_TEXTS): + user = users[index % len(users)] + if user is not None: + _news_for_source(user, (text,)).delete() + if project is not None: + _news_for_source(project, DEMO_PROJECT_NEWS_TEXTS).delete() + project.delete() + + +def _ensure_project(*, leader) -> Project: + project = _find_owned_project(expected_leader=leader) + if project is None: + return Project.objects.create( + name=DEMO_NEWS_PROJECT_NAME, + description=DEMO_NEWS_PROJECT_DESCRIPTION, + leader=leader, + draft=False, + is_public=True, + ) + + project.description = DEMO_NEWS_PROJECT_DESCRIPTION + project.draft = False + project.is_public = True + project.save( + update_fields=( + "description", + "draft", + "is_public", + "datetime_updated", + ) + ) + return project + + +def _ensure_news(*, source, text, audience, datetime_created) -> News: + candidates = list(_news_for_source(source, (text,)).order_by("pk")) + if len(candidates) > 1: + raise ReactDevNewsDemoDataError(f"Найдены дубли точной DEMO-новости: {text}") + + if candidates: + news = candidates[0] + news.audience = audience + news.pin = False + news.save(update_fields=("audience", "pin", "datetime_updated")) + news.files.clear() + else: + news = News.objects.add_news( + source, + text=text, + files=[], + audience=audience, + ) + + # Даты задаются явно, чтобы порядок страниц был стабильным и отличался у записей. + News.objects.filter(pk=news.pk).update( + datetime_created=datetime_created, + datetime_updated=datetime_created, + ) + news.datetime_created = datetime_created + news.datetime_updated = datetime_created + return news + + +def _ensure_news_group(*, source, texts, start_offset_hours) -> tuple[News, ...]: + now = timezone.now() + return tuple( + _ensure_news( + source=source, + text=text, + audience=News.Audience.PLATFORM, + datetime_created=now - timezone.timedelta(hours=start_offset_hours + index), + ) + for index, text in enumerate(texts) + ) + + +def _ensure_reactions_and_comments(*, result, users_by_key) -> None: + users = tuple(users_by_key.values()) + news_content_type = ContentType.objects.get_for_model(News) + targets = ( + (result.program_news[0], users[:2], users[:3]), + (result.project_news[0], users[:1], users[:2]), + (result.user_news[0], users[:3], users), + ) + for news, like_users, view_users in targets: + for user in like_users: + Like.objects.get_or_create( + user=user, + content_type=news_content_type, + object_id=news.pk, + ) + for user in view_users: + View.objects.get_or_create( + user=user, + content_type=news_content_type, + object_id=news.pk, + ) + + comment_start = result.program_news[0].datetime_created + for index, text in enumerate(DEMO_PAGINATED_COMMENT_TEXTS): + comment, _created = NewsComment.objects.get_or_create( + news=result.program_news[0], + author=users[index % len(users)], + text=text, + ) + NewsComment.objects.filter(pk=comment.pk).update( + datetime_created=comment_start + timezone.timedelta(minutes=index + 1), + datetime_updated=None, + ) + + for news, text, author in ( + (result.project_news[0], DEMO_EXTRA_COMMENT_TEXTS[0], users[1]), + (result.user_news[0], DEMO_EXTRA_COMMENT_TEXTS[1], users[2]), + ): + NewsComment.objects.get_or_create(news=news, author=author, text=text) + + +def ensure_react_dev_news_demo_data(*, program, users_by_key) -> ReactDevNewsDemoResult: + """Создать или обновить точный набор DEV-083 без дублирования записей.""" + + project = _ensure_project(leader=users_by_key["participant1"]) + program_news = _ensure_news_group( + source=program, + texts=DEMO_PROGRAM_NEWS_TEXTS, + start_offset_hours=1, + ) + project_news = _ensure_news_group( + source=project, + texts=DEMO_PROJECT_NEWS_TEXTS, + start_offset_hours=20, + ) + + users = tuple(users_by_key.values()) + user_news = tuple( + _ensure_news( + source=users[index % len(users)], + text=text, + audience=News.Audience.PLATFORM, + datetime_created=timezone.now() - timezone.timedelta(hours=40 + index), + ) + for index, text in enumerate(DEMO_USER_NEWS_TEXTS) + ) + internal_news = _ensure_news( + source=program, + text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT, + audience=News.Audience.PROGRAM_PARTICIPANTS, + datetime_created=timezone.now() - timezone.timedelta(minutes=30), + ) + + result = ReactDevNewsDemoResult( + project=project, + program_news=program_news, + project_news=project_news, + user_news=user_news, + internal_news=internal_news, + ) + _ensure_reactions_and_comments(result=result, users_by_key=users_by_key) + return result diff --git a/partner_programs/tests/test_react_dev_demo_seed.py b/partner_programs/tests/test_react_dev_demo_seed.py index 32285912..b1d01978 100644 --- a/partner_programs/tests/test_react_dev_demo_seed.py +++ b/partner_programs/tests/test_react_dev_demo_seed.py @@ -1,4 +1,4 @@ -# Roadmap: DEV-072 +# Roadmap: DEV-072, DEV-083 # Безопасность, повторяемость и API-контракт демонстрационного React-dev набора. import io @@ -6,12 +6,15 @@ import secrets from unittest.mock import patch +from django.contrib.contenttypes.models import ContentType from django.core.management import call_command from django.core.management.base import CommandError from django.test import TestCase, override_settings from django.urls import include, path from rest_framework.test import APIClient +from core.models import Like, View +from news.models import News, NewsComment from partner_programs.models import ( Application, Evaluation, @@ -30,12 +33,22 @@ DEMO_SUBMISSION_SPECS, DEMO_USER_SPECS, ) +from partner_programs.services.react_dev_news_demo import ( + DEMO_INTERNAL_PROGRAM_NEWS_TEXT, + DEMO_NEWS_PROJECT_NAME, + DEMO_PAGINATED_COMMENT_TEXTS, + DEMO_PROGRAM_NEWS_TEXTS, + DEMO_PROJECT_NEWS_TEXTS, + DEMO_USER_NEWS_TEXTS, +) from partner_programs.tests.helpers import create_partner_program, create_user from project_rates.models import Criteria +from projects.models import Project from users.models import CustomUser urlpatterns = [ path("expert/", include("partner_programs.expert_urls")), + path("feed/", include("feed.urls")), ] @@ -76,6 +89,10 @@ def assert_demo_absent(self): email__in=[spec["email"] for spec in DEMO_USER_SPECS] ).exists() ) + self.assertFalse(Project.objects.filter(name=DEMO_NEWS_PROJECT_NAME).exists()) + self.assertFalse( + News.objects.filter(text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT).exists() + ) def test_seed_is_disabled_by_default_before_any_write(self): with patch.dict( @@ -197,6 +214,122 @@ def test_first_run_creates_the_complete_linked_dataset(self): SubmissionExpertAssignment.STATUS_COMPLETED, ) + project = Project.objects.get(name=DEMO_NEWS_PROJECT_NAME) + self.assertEqual(project.leader, users["participant1"]) + self.assertFalse(project.draft) + self.assertTrue(project.is_public) + self.assertEqual(len(self.news_ids_for(program, DEMO_PROGRAM_NEWS_TEXTS)), 11) + self.assertEqual(len(self.news_ids_for(project, DEMO_PROJECT_NEWS_TEXTS)), 11) + self.assertEqual( + News.objects.filter(text__in=DEMO_USER_NEWS_TEXTS).count(), + 11, + ) + internal = News.objects.get(text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT) + self.assertEqual(internal.content_object, program) + self.assertEqual(internal.audience, News.Audience.PROGRAM_PARTICIPANTS) + seeded_news = News.objects.filter( + text__in=( + *DEMO_PROGRAM_NEWS_TEXTS, + *DEMO_PROJECT_NEWS_TEXTS, + *DEMO_USER_NEWS_TEXTS, + DEMO_INTERNAL_PROGRAM_NEWS_TEXT, + ) + ) + self.assertEqual(seeded_news.values("text").distinct().count(), 34) + self.assertGreater(seeded_news.values("datetime_created").distinct().count(), 3) + + def test_each_public_feed_tab_has_a_second_page_without_duplicates(self): + self.run_seed() + participant = CustomUser.objects.get(email="demo.participant1@procollab.test") + client = APIClient() + client.force_authenticate(participant) + + for source in ("program", "project", "user"): + with self.subTest(source=source): + first = client.get("/feed/news/", {"source": source}) + second = client.get( + "/feed/news/", + {"source": source, "offset": 10}, + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.data["count"], 11) + self.assertEqual(len(first.data["results"]), 10) + self.assertEqual(len(second.data["results"]), 1) + first_ids = {item["id"] for item in first.data["results"]} + second_ids = {item["id"] for item in second.data["results"]} + self.assertFalse(first_ids.intersection(second_ids)) + + def test_feed_exposes_seeded_reactions_views_and_paginated_comments(self): + self.run_seed() + participant = CustomUser.objects.get(email="demo.participant1@procollab.test") + news = News.objects.get(text=DEMO_PROGRAM_NEWS_TEXTS[0]) + client = APIClient() + client.force_authenticate(participant) + + feed = client.get("/feed/news/", {"source": "program", "limit": 100}) + first_comments = client.get(f"/feed/news/{news.pk}/comments/") + second_comments = client.get( + f"/feed/news/{news.pk}/comments/", + {"offset": 20}, + ) + + item = next(item for item in feed.data["results"] if item["id"] == news.pk) + self.assertEqual(item["likes_count"], 2) + self.assertEqual(item["views_count"], 3) + self.assertEqual(item["comments_count"], 21) + self.assertEqual(first_comments.data["count"], 21) + self.assertEqual(len(first_comments.data["results"]), 20) + self.assertEqual(len(second_comments.data["results"]), 1) + comment_ids = [ + *(comment["id"] for comment in first_comments.data["results"]), + *(comment["id"] for comment in second_comments.data["results"]), + ] + self.assertEqual(len(comment_ids), len(set(comment_ids))) + self.assertEqual( + [comment["text"] for comment in first_comments.data["results"]] + + [comment["text"] for comment in second_comments.data["results"]], + list(DEMO_PAGINATED_COMMENT_TEXTS), + ) + + def test_internal_program_news_is_detail_only_for_program_member(self): + self.run_seed() + participant = CustomUser.objects.get(email="demo.participant1@procollab.test") + outsider = create_user(prefix="react-news-demo-outsider") + internal = News.objects.get(text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT) + client = APIClient() + client.force_authenticate(participant) + + list_response = client.get("/feed/news/", {"source": "program", "limit": 100}) + member_detail = client.get(f"/feed/news/{internal.pk}/") + client.force_authenticate(outsider) + outsider_detail = client.get(f"/feed/news/{internal.pk}/") + + self.assertNotIn( + internal.pk, + [item["id"] for item in list_response.data["results"]], + ) + self.assertEqual(member_detail.status_code, 200) + self.assertEqual( + member_detail.data["audience"], + News.Audience.PROGRAM_PARTICIPANTS, + ) + self.assertEqual(outsider_detail.status_code, 404) + + def test_command_reports_news_counts_and_internal_news_id(self): + stdout, _stderr = self.run_seed() + internal = News.objects.get(text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT) + + self.assertIn("программы: 1", stdout) + self.assertIn("проекты: 1", stdout) + self.assertIn("публичные новости: 33", stdout) + self.assertIn("внутренние новости: 1", stdout) + self.assertIn("лайки: 6", stdout) + self.assertIn("просмотры: 9", stdout) + self.assertIn("комментарии: 23", stdout) + self.assertIn(f"ID внутренней новости: {internal.pk}", stdout) + def test_repeated_run_does_not_create_duplicates(self): self.run_seed() first_ids = self.dataset_ids() @@ -210,6 +343,11 @@ def test_repeated_run_does_not_create_duplicates(self): self.assertEqual(len(first_ids["assignments"]), 3) self.assertEqual(len(first_ids["evaluations"]), 2) self.assertEqual(len(first_ids["scores"]), 4) + self.assertEqual(len(first_ids["projects"]), 1) + self.assertEqual(len(first_ids["news"]), 34) + self.assertEqual(len(first_ids["likes"]), 6) + self.assertEqual(len(first_ids["views"]), 9) + self.assertEqual(len(first_ids["comments"]), 23) program = PartnerProgram.objects.get(name=DEMO_PROGRAM_NAME) draft = Evaluation.objects.get( @@ -241,6 +379,8 @@ def test_dry_run_reports_counts_without_persisting_changes(self): self.assertIn("Пробный запуск завершен", stdout) self.assertIn("сдачи: 3", stdout) + self.assertIn("публичные новости: 33", stdout) + self.assertIn("внутренние новости: 1", stdout) self.assert_demo_absent() self.run_seed() @@ -260,6 +400,10 @@ def test_reset_rebuilds_only_demo_contour(self): self.run_seed() original_program = PartnerProgram.objects.get(name=DEMO_PROGRAM_NAME) original_program_id = original_program.pk + original_project_id = Project.objects.get(name=DEMO_NEWS_PROJECT_NAME).pk + original_internal_news_id = News.objects.get( + text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT + ).pk demo_user_ids = set( CustomUser.objects.filter( email__in=[spec["email"] for spec in DEMO_USER_SPECS] @@ -270,6 +414,19 @@ def test_reset_rebuilds_only_demo_contour(self): name="[DEMO] Похожая, но посторонняя программа", tag="other-demo-program", ) + demo_user = CustomUser.objects.get(email="demo.participant1@procollab.test") + unrelated_news = News.objects.add_news( + demo_user, + text="[DEMO] Посторонняя новость DEMO-пользователя", + audience=News.Audience.PLATFORM, + ) + unrelated_project = Project.objects.create( + leader=outsider, + name="[DEMO] Другой публичный проект", + description="Не принадлежит seed-набору DEV-083.", + draft=False, + is_public=True, + ) self.run_seed("--reset") @@ -278,8 +435,18 @@ def test_reset_rebuilds_only_demo_contour(self): tag=DEMO_PROGRAM_TAG, ) self.assertNotEqual(rebuilt.pk, original_program_id) + self.assertNotEqual( + Project.objects.get(name=DEMO_NEWS_PROJECT_NAME).pk, + original_project_id, + ) + self.assertNotEqual( + News.objects.get(text=DEMO_INTERNAL_PROGRAM_NEWS_TEXT).pk, + original_internal_news_id, + ) self.assertTrue(CustomUser.objects.filter(pk=outsider.pk).exists()) self.assertTrue(PartnerProgram.objects.filter(pk=outsider_program.pk).exists()) + self.assertTrue(News.objects.filter(pk=unrelated_news.pk).exists()) + self.assertTrue(Project.objects.filter(pk=unrelated_project.pk).exists()) self.assertEqual( set( CustomUser.objects.filter( @@ -296,6 +463,39 @@ def test_reset_rebuilds_only_demo_contour(self): ).count(), 4, ) + self.assertEqual(Project.objects.filter(name=DEMO_NEWS_PROJECT_NAME).count(), 1) + self.assertEqual( + News.objects.filter( + text__in=( + *DEMO_PROGRAM_NEWS_TEXTS, + *DEMO_PROJECT_NEWS_TEXTS, + *DEMO_USER_NEWS_TEXTS, + DEMO_INTERNAL_PROGRAM_NEWS_TEXT, + ) + ).count(), + 34, + ) + + def test_foreign_exact_project_name_aborts_without_partial_writes(self): + outsider = create_user(prefix="react-news-demo-project-owner") + project = Project.objects.create( + leader=outsider, + name=DEMO_NEWS_PROJECT_NAME, + description="Посторонний проект с конфликтующим точным именем.", + draft=False, + is_public=True, + ) + + with self.assertRaisesMessage(CommandError, "занято посторонним проектом"): + self.run_seed() + + self.assertTrue(Project.objects.filter(pk=project.pk).exists()) + self.assertFalse(PartnerProgram.objects.filter(name=DEMO_PROGRAM_NAME).exists()) + self.assertFalse( + CustomUser.objects.filter( + email__in=[spec["email"] for spec in DEMO_USER_SPECS] + ).exists() + ) def test_expert_api_returns_three_expected_evaluation_states(self): self.run_seed() @@ -373,6 +573,15 @@ def dataset_ids(self): name=DEMO_PROGRAM_NAME, tag=DEMO_PROGRAM_TAG, ) + news = News.objects.filter( + text__in=( + *DEMO_PROGRAM_NEWS_TEXTS, + *DEMO_PROJECT_NEWS_TEXTS, + *DEMO_USER_NEWS_TEXTS, + DEMO_INTERNAL_PROGRAM_NEWS_TEXT, + ) + ) + news_content_type = ContentType.objects.get_for_model(News) return { "users": tuple( CustomUser.objects.filter( @@ -407,4 +616,43 @@ def dataset_ids(self): .order_by("pk") .values_list("pk", flat=True) ), + "projects": tuple( + Project.objects.filter(name=DEMO_NEWS_PROJECT_NAME) + .order_by("pk") + .values_list("pk", flat=True) + ), + "news": tuple(news.order_by("pk").values_list("pk", flat=True)), + "likes": tuple( + Like.objects.filter( + content_type=news_content_type, + object_id__in=news.values("pk"), + ) + .order_by("pk") + .values_list("pk", flat=True) + ), + "views": tuple( + View.objects.filter( + content_type=news_content_type, + object_id__in=news.values("pk"), + ) + .order_by("pk") + .values_list("pk", flat=True) + ), + "comments": tuple( + NewsComment.objects.filter(news__in=news) + .order_by("pk") + .values_list("pk", flat=True) + ), } + + def news_ids_for(self, source, texts): + content_type = ContentType.objects.get_for_model(source) + return tuple( + News.objects.filter( + content_type=content_type, + object_id=source.pk, + text__in=texts, + ) + .order_by("pk") + .values_list("pk", flat=True) + )