refactor(communications): нормализация Message модели (Phase 2)
Создание новых нормализованных моделей: - 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
This commit is contained in:
parent
33dc9281ea
commit
003bb12163
@ -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 = (
|
||||
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user