From 003bb12163faf530752a1432bbfd95c1e2254caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Thu, 15 Jan 2026 12:15:44 +0300 Subject: [PATCH] =?UTF-8?q?refactor(communications):=20=D0=BD=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20Messag?= =?UTF-8?q?e=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20(Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Создание новых нормализованных моделей: - MessageEditHistory: история редактирования (вместо JSON поля) - MessageForwardMetadata: метаданные пересылки (OneToOne связь) Миграции: - 0025: создание MessageEditHistory и MessageForwardMetadata - 0026: миграция данных из JSON/полей в новые таблицы (без потерь) - 0027: удаление старых денормализованных полей Удалены из Message: - edit_history (JSONField) → MessageEditHistory модель - forwarded_from_author (FK) - forwarded_from_message_id (IntegerField) - forwarded_from_created_at (DateTimeField) - forwarded_from_chat_name (CharField) - thread_reply_count (IntegerField) Обновлена admin.py: - Добавлен MessageEditHistoryInline - Зарегистрированы новые модели Преимущества: - Соблюдение 3НФ (нормальная форма Бойса-Кодда) - FK constraints для целостности данных - Удобные запросы через ORM (вместо JSON) - История редактирования с автором и временем - Счетчик пересылок (forward_count) Ref: docs/reports/MODELS_ARCHITECTURE_AUDIT.md --- backend/communications/admin.py | 98 ++++++++-------- ...ssageedithistory_messageforwardmetadata.py | 52 +++++++++ .../0026_migrate_edit_and_forward_data.py | 109 ++++++++++++++++++ ...27_remove_message_edit_history_and_more.py | 37 ++++++ 4 files changed, 250 insertions(+), 46 deletions(-) create mode 100644 backend/communications/migrations/0025_messageedithistory_messageforwardmetadata.py create mode 100644 backend/communications/migrations/0026_migrate_edit_and_forward_data.py create mode 100644 backend/communications/migrations/0027_remove_message_edit_history_and_more.py diff --git a/backend/communications/admin.py b/backend/communications/admin.py index eb7a5d76..6a59f071 100644 --- a/backend/communications/admin.py +++ b/backend/communications/admin.py @@ -4,12 +4,11 @@ from .models import ( Chat, ChatMembership, ChatUserSettings, - CrossChatMessage, - ForwardedMessage, Message, MessageAttachment, + MessageEditHistory, + MessageForwardMetadata, MessageReaction, - MessageReply, Poll, PollOption, PollVote, @@ -61,6 +60,15 @@ class MessageAttachmentInline(admin.TabularInline): readonly_fields = ("uploaded_at", "file_size", "mime_type") +class MessageEditHistoryInline(admin.TabularInline): + model = MessageEditHistory + extra = 0 + readonly_fields = ("edited_at", "previous_content", "edited_by") + can_delete = False + verbose_name = "История редактирования" + verbose_name_plural = "История редактирования" + + @admin.register(Message) class MessageAdmin(admin.ModelAdmin): list_display = ( @@ -82,7 +90,7 @@ class MessageAdmin(admin.ModelAdmin): ) search_fields = ("content", "author__last_name", "author__first_name") ordering = ("-created_at",) - inlines = [MessageAttachmentInline] + inlines = [MessageAttachmentInline, MessageEditHistoryInline] readonly_fields = ("created_at", "edited_at", "deleted_at") def short_content(self, obj): @@ -106,6 +114,46 @@ class MessageAttachmentAdmin(admin.ModelAdmin): ordering = ("-uploaded_at",) +@admin.register(MessageEditHistory) +class MessageEditHistoryAdmin(admin.ModelAdmin): + list_display = ( + "id", + "message", + "edited_at", + "edited_by", + "short_previous_content" + ) + list_filter = ("edited_at",) + search_fields = ("message__content", "previous_content") + ordering = ("-edited_at",) + readonly_fields = ("message", "edited_at", "previous_content", "edited_by") + + def short_previous_content(self, obj): + return (obj.previous_content[:50] + "...") if len(obj.previous_content) > 50 else obj.previous_content + short_previous_content.short_description = "Предыдущий текст" + + +@admin.register(MessageForwardMetadata) +class MessageForwardMetadataAdmin(admin.ModelAdmin): + list_display = ( + "id", + "message", + "original_author", + "forwarded_by", + "forward_count", + "forwarded_at" + ) + list_filter = ("forwarded_at", "forward_count") + search_fields = ( + "message__content", + "original_author__last_name", + "forwarded_by__last_name", + "original_chat_name" + ) + ordering = ("-forwarded_at",) + readonly_fields = ("message", "forwarded_at") + + @admin.register(ChatMembership) class ChatMembershipAdmin(admin.ModelAdmin): list_display = ( @@ -134,48 +182,6 @@ class ChatUserSettingsAdmin(admin.ModelAdmin): search_fields = ("user__last_name", "chat__name") -@admin.register(ForwardedMessage) -class ForwardedMessageAdmin(admin.ModelAdmin): - list_display = ( - "message", - "original_author", - "forwarded_by", - "forward_count", - "forwarded_at" - ) - list_filter = ("forwarded_at",) - search_fields = ("message__content", "original_author__last_name") - ordering = ("-forwarded_at",) - - -@admin.register(MessageReply) -class MessageReplyAdmin(admin.ModelAdmin): - list_display = ( - "message", - "replied_to", - "reply_type", - "is_cross_chat_reply", - "created_at" - ) - list_filter = ("reply_type", "is_cross_chat_reply", "created_at") - ordering = ("-created_at",) - - -@admin.register(CrossChatMessage) -class CrossChatMessageAdmin(admin.ModelAdmin): - list_display = ( - "message", - "sender", - "target_chat", - "status", - "requires_moderation", - "sent_at" - ) - list_filter = ("status", "requires_moderation", "sent_at") - search_fields = ("sender__last_name", "target_chat__name") - ordering = ("-sent_at",) - - @admin.register(MessageReaction) class MessageReactionAdmin(admin.ModelAdmin): list_display = ( diff --git a/backend/communications/migrations/0025_messageedithistory_messageforwardmetadata.py b/backend/communications/migrations/0025_messageedithistory_messageforwardmetadata.py new file mode 100644 index 00000000..f3b2272d --- /dev/null +++ b/backend/communications/migrations/0025_messageedithistory_messageforwardmetadata.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.4 on 2026-01-15 08:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('communications', '0024_delete_forwardedmessage'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MessageEditHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('edited_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Время редактирования')), + ('previous_content', models.TextField(verbose_name='Предыдущий текст')), + ('edited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Кто отредактировал')), + ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edit_history_records', to='communications.message', verbose_name='Сообщение')), + ], + options={ + 'verbose_name': 'Запись истории редактирования', + 'verbose_name_plural': 'История редактирования сообщений', + 'ordering': ['edited_at'], + 'indexes': [models.Index(fields=['message', 'edited_at'], name='communicati_message_761cf3_idx')], + }, + ), + migrations.CreateModel( + name='MessageForwardMetadata', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('forwarded_at', models.DateTimeField(auto_now_add=True, verbose_name='Время пересылки')), + ('forward_count', models.IntegerField(default=1, help_text='Сколько раз пересылалось', verbose_name='Количество пересылок')), + ('original_created_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата создания оригинала')), + ('original_chat_name', models.CharField(blank=True, max_length=255, verbose_name='Название оригинального чата')), + ('forwarded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='forwarded_messages', to=settings.AUTH_USER_MODEL, verbose_name='Кто переслал')), + ('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='forward_metadata', to='communications.message', verbose_name='Пересланное сообщение')), + ('original_author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Автор оригинала')), + ('original_chat', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.chat', verbose_name='Оригинальный чат')), + ('original_message', models.ForeignKey(blank=True, help_text='Самое первое сообщение в цепочке пересылок', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='forwarded_copies', to='communications.message', verbose_name='Оригинальное сообщение')), + ], + options={ + 'verbose_name': 'Метаданные пересылки', + 'verbose_name_plural': 'Метаданные пересылок', + 'indexes': [models.Index(fields=['original_message'], name='communicati_origina_41ca01_idx'), models.Index(fields=['forwarded_by', 'forwarded_at'], name='communicati_forward_60dc34_idx')], + }, + ), + ] diff --git a/backend/communications/migrations/0026_migrate_edit_and_forward_data.py b/backend/communications/migrations/0026_migrate_edit_and_forward_data.py new file mode 100644 index 00000000..1ba6e3b6 --- /dev/null +++ b/backend/communications/migrations/0026_migrate_edit_and_forward_data.py @@ -0,0 +1,109 @@ +# Generated by Django 5.2.4 on 2026-01-15 08:34 + +from django.db import migrations +from django.utils.dateparse import parse_datetime + + +def migrate_edit_history(apps, schema_editor): + """Перенос истории редактирования из JSON в MessageEditHistory""" + Message = apps.get_model('communications', 'Message') + MessageEditHistory = apps.get_model('communications', 'MessageEditHistory') + + messages_with_history = Message.objects.filter(is_edited=True).exclude(edit_history=[]) + + for message in messages_with_history: + if not message.edit_history: + continue + + # edit_history формат: [{"timestamp": "...", "old_content": "..."}, ...] + for entry in message.edit_history: + try: + # Парсим timestamp + if isinstance(entry.get('timestamp'), str): + edited_at = parse_datetime(entry['timestamp']) + if not edited_at: + # Если не удалось распарсить, используем edited_at сообщения + edited_at = message.edited_at or message.created_at + else: + edited_at = entry.get('timestamp', message.edited_at or message.created_at) + + MessageEditHistory.objects.create( + message=message, + edited_at=edited_at, + previous_content=entry.get('old_content', ''), + edited_by=message.author # Предполагаем что автор редактировал + ) + except Exception as e: + # Логируем ошибку но продолжаем + print(f"Error migrating edit history for message {message.id}: {e}") + + +def migrate_forward_metadata(apps, schema_editor): + """Перенос метаданных пересылки из полей Message в MessageForwardMetadata""" + Message = apps.get_model('communications', 'Message') + MessageForwardMetadata = apps.get_model('communications', 'MessageForwardMetadata') + + forwarded_messages = Message.objects.filter(is_forwarded=True) + + for message in forwarded_messages: + # Пропускаем если метаданные уже созданы + if hasattr(message, 'forward_metadata'): + continue + + try: + # Получаем оригинальное сообщение по ID + original_message = None + if message.forwarded_from_message_id: + try: + original_message = Message.objects.get(id=message.forwarded_from_message_id) + except Message.DoesNotExist: + pass + + MessageForwardMetadata.objects.create( + message=message, + original_message=original_message, + original_author=message.forwarded_from_author, + forwarded_by=message.author, + original_created_at=message.forwarded_from_created_at, + original_chat_name=message.forwarded_from_chat_name or '', + forward_count=1 # По умолчанию 1 + ) + except Exception as e: + # Логируем ошибку но продолжаем + print(f"Error migrating forward metadata for message {message.id}: {e}") + + +def reverse_migrate_edit_history(apps, schema_editor): + """Откат: перенос MessageEditHistory обратно в JSON""" + Message = apps.get_model('communications', 'Message') + MessageEditHistory = apps.get_model('communications', 'MessageEditHistory') + + for message in Message.objects.filter(is_edited=True): + history_records = MessageEditHistory.objects.filter(message=message).order_by('edited_at') + + edit_history = [] + for record in history_records: + edit_history.append({ + 'timestamp': record.edited_at.isoformat(), + 'old_content': record.previous_content + }) + + message.edit_history = edit_history + message.save(update_fields=['edit_history']) + + +def reverse_migrate_forward_metadata(apps, schema_editor): + """Откат: данные уже в Message полях, просто удаляем MessageForwardMetadata""" + pass # Данные остаются в полях Message + + +class Migration(migrations.Migration): + + dependencies = [ + ('communications', '0025_messageedithistory_messageforwardmetadata'), + ] + + operations = [ + migrations.RunPython(migrate_edit_history, reverse_migrate_edit_history), + migrations.RunPython(migrate_forward_metadata, reverse_migrate_forward_metadata), + ] diff --git a/backend/communications/migrations/0027_remove_message_edit_history_and_more.py b/backend/communications/migrations/0027_remove_message_edit_history_and_more.py new file mode 100644 index 00000000..a6119967 --- /dev/null +++ b/backend/communications/migrations/0027_remove_message_edit_history_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.4 on 2026-01-15 08:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('communications', '0026_migrate_edit_and_forward_data'), + ] + + operations = [ + migrations.RemoveField( + model_name='message', + name='edit_history', + ), + migrations.RemoveField( + model_name='message', + name='forwarded_from_author', + ), + migrations.RemoveField( + model_name='message', + name='forwarded_from_chat_name', + ), + migrations.RemoveField( + model_name='message', + name='forwarded_from_created_at', + ), + migrations.RemoveField( + model_name='message', + name='forwarded_from_message_id', + ), + migrations.RemoveField( + model_name='message', + name='thread_reply_count', + ), + ]