From c1e36a56b1c00c16b3452d9d50b43b0060f1564d Mon Sep 17 00:00:00 2001 From: Fedor Date: Fri, 21 Aug 2026 10:12:06 +0300 Subject: [PATCH] fix: make news audience migration non-atomic --- .../0010_news_audience_newscomment.py | 121 ++++++++++++++++++ news/models.py | 54 ++++++++ news/tests/test_news_migration_0010.py | 81 ++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 news/migrations/0010_news_audience_newscomment.py create mode 100644 news/tests/test_news_migration_0010.py diff --git a/news/migrations/0010_news_audience_newscomment.py b/news/migrations/0010_news_audience_newscomment.py new file mode 100644 index 00000000..903f341d --- /dev/null +++ b/news/migrations/0010_news_audience_newscomment.py @@ -0,0 +1,121 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +def set_existing_news_audiences(apps, schema_editor): + News = apps.get_model("news", "News") + ContentType = apps.get_model("contenttypes", "ContentType") + + News.objects.all().update(audience="platform") + program_content_type_id = ( + ContentType.objects.filter( + app_label="partner_programs", + model="partnerprogram", + ) + .values_list("id", flat=True) + .first() + ) + if program_content_type_id is not None: + # Старые новости программ считаются внутренними: миграция не должна + # случайно опубликовать их во всей платформе. + News.objects.filter(content_type_id=program_content_type_id).update( + audience="program_participants" + ) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("news", "0009_news_pin"), + ] + + operations = [ + migrations.AddField( + model_name="news", + name="audience", + field=models.CharField( + choices=[ + ("platform", "Вся платформа"), + ("program_participants", "Участники программы"), + ], + db_index=True, + default="platform", + max_length=24, + verbose_name="Аудитория", + ), + ), + migrations.RunPython( + set_existing_news_audiences, + migrations.RunPython.noop, + ), + migrations.AddConstraint( + model_name="news", + constraint=models.CheckConstraint( + check=models.Q(audience__in=("platform", "program_participants")), + name="news_valid_audience", + ), + ), + migrations.CreateModel( + name="NewsComment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField(max_length=2000, verbose_name="Текст")), + ( + "datetime_created", + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="Дата создания", + ), + ), + ( + "datetime_updated", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Дата изменения", + ), + ), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="news_comments", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор", + ), + ), + ( + "news", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="news.news", + verbose_name="Новость", + ), + ), + ], + options={ + "verbose_name": "Комментарий к новости", + "verbose_name_plural": "Комментарии к новостям", + "ordering": ["datetime_created", "id"], + "indexes": [ + models.Index( + fields=["news", "datetime_created"], + name="news_comment_order_idx", + ) + ], + }, + ), + ] diff --git a/news/models.py b/news/models.py index a1a5a189..d52ab1ef 100644 --- a/news/models.py +++ b/news/models.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType +from django.conf import settings from django.db import models from django.utils import timezone @@ -10,6 +11,10 @@ class News(models.Model): + class Audience(models.TextChoices): + PLATFORM = "platform", "Вся платформа" + PROGRAM_PARTICIPANTS = "program_participants", "Участники программы" + content_type = models.ForeignKey( ContentType, on_delete=models.CASCADE, @@ -38,6 +43,13 @@ class News(models.Model): verbose_name="Закрепить новость", help_text="Закрепить новость (пока только для профиля программ)", ) + audience = models.CharField( + max_length=24, + choices=Audience.choices, + default=Audience.PLATFORM, + db_index=True, + verbose_name="Аудитория", + ) datetime_created = models.DateTimeField( verbose_name="Дата создания", null=False, default=timezone.now ) @@ -53,3 +65,45 @@ class Meta(TypedModelMeta): verbose_name = "Новость" verbose_name_plural = "Новости" ordering = ["-datetime_created"] + constraints = [ + models.CheckConstraint( + check=models.Q(audience__in=("platform", "program_participants")), + name="news_valid_audience", + ) + ] + + +class NewsComment(models.Model): + news = models.ForeignKey( + News, + on_delete=models.CASCADE, + related_name="comments", + verbose_name="Новость", + ) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="news_comments", + verbose_name="Автор", + ) + text = models.TextField(max_length=2000, verbose_name="Текст") + datetime_created = models.DateTimeField( + default=timezone.now, + verbose_name="Дата создания", + ) + datetime_updated = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата изменения", + ) + + class Meta(TypedModelMeta): + verbose_name = "Комментарий к новости" + verbose_name_plural = "Комментарии к новостям" + ordering = ["datetime_created", "id"] + indexes = [ + models.Index( + fields=["news", "datetime_created"], + name="news_comment_order_idx", + ) + ] diff --git a/news/tests/test_news_migration_0010.py b/news/tests/test_news_migration_0010.py new file mode 100644 index 00000000..fc42639c --- /dev/null +++ b/news/tests/test_news_migration_0010.py @@ -0,0 +1,81 @@ +from importlib import import_module +import unittest + +from django.db import connection, migrations +from django.db.migrations.executor import MigrationExecutor +from django.test import SimpleTestCase, TransactionTestCase + + +class NewsMigration0010MetadataTests(SimpleTestCase): + def test_migration_is_non_atomic(self): + migration = import_module("news.migrations.0010_news_audience_newscomment") + + self.assertIs(migration.Migration.atomic, False) + + def test_runpython_does_not_reintroduce_atomic_transaction(self): + migration = import_module("news.migrations.0010_news_audience_newscomment") + run_python_operations = [ + operation + for operation in migration.Migration.operations + if isinstance(operation, migrations.RunPython) + ] + + self.assertEqual(len(run_python_operations), 1) + self.assertIsNone(run_python_operations[0].atomic) + + +@unittest.skipUnless( + connection.vendor == "postgresql", + "PostgreSQL-only regression for pending trigger events during migration 0010.", +) +class NewsMigration0010PostgreSQLTests(TransactionTestCase): + migrate_from = [("news", "0009_news_pin")] + migrate_to = [("news", "0010_news_audience_newscomment")] + + def setUp(self): + super().setUp() + self.executor = MigrationExecutor(connection) + self.executor.migrate(self.migrate_from) + self.apps = self.executor.loader.project_state(self.migrate_from).apps + + def tearDown(self): + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_to) + super().tearDown() + + def test_applies_after_updating_existing_news_rows(self): + ContentType = self.apps.get_model("contenttypes", "ContentType") + News = self.apps.get_model("news", "News") + project_content_type, _ = ContentType.objects.get_or_create( + app_label="projects", + model="project", + ) + program_content_type, _ = ContentType.objects.get_or_create( + app_label="partner_programs", + model="partnerprogram", + ) + + News.objects.create( + content_type=project_content_type, + object_id=1, + text="Project news", + ) + News.objects.create( + content_type=program_content_type, + object_id=1, + text="Program news", + ) + + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_to) + migrated_apps = self.executor.loader.project_state(self.migrate_to).apps + MigratedNews = migrated_apps.get_model("news", "News") + + self.assertEqual( + MigratedNews.objects.get(content_type_id=project_content_type.id).audience, + "platform", + ) + self.assertEqual( + MigratedNews.objects.get(content_type_id=program_content_type.id).audience, + "program_participants", + )