Files
vontor-cz/backend/social/chat/models.py
David Bruno Vontor b1f88ca501 Restore reactions, add constraint, handle errors
Restore soft-deleted message reactions and enforce uniqueness only for active reactions; add WebSocket error handling and minor UI/Docker tweaks.

- backend/social/chat/models.py: Toggle reaction now restores a stale soft-deleted MessageReaction (avoids unique conflicts) and creates new reactions as needed. Replaced unique_together with a conditional UniqueConstraint that applies only to non-deleted records.
- backend/social/chat/consumers.py: Wrap reaction toggle in try/except to return a WS error message on failure instead of allowing exceptions to bubble up.
- frontend/src/components/social/chat/Message.tsx: Adjusted Tailwind max-width class for the reaction menu (max-w-32).
- docker-compose.yml: Added commented example configuration for an optional Janus media server (documentational/commented service).

These changes prevent unique constraint errors when restoring reactions, improve robustness of the WebSocket reaction flow, and include small UI and deployment notes.
2026-06-04 16:45:16 +02:00

244 lines
6.9 KiB
Python

from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
from vontor_cz.custom_fields import WebPImageField
from vontor_cz.models import SoftDeleteModel
class Chat(SoftDeleteModel):
AUTHOR_FIELD = 'owner'
class ChatType(models.TextChoices):
DM = 'DM', 'Direct Message'
GROUP = 'GROUP', 'Group'
chat_type = models.CharField(
max_length=10,
choices=ChatType.choices,
default=ChatType.GROUP,
)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='owned_chats'
)
name = models.CharField(max_length=255, blank=True)
icon = WebPImageField(upload_to='chat_icons/', null=True, blank=True)
banner = WebPImageField(upload_to='chat_banners/', null=True, blank=True)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='chats',
blank=True
)
moderators = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='moderated_chats',
blank=True
)
hub = models.ForeignKey(
'hubs.Hub',
on_delete=models.CASCADE,
null=True,
blank=True
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
is_new = self._state.adding
super().save(*args, **kwargs)
# LOGIC: Ensure owner is always a member and moderator
if is_new and self.owner:
self.members.add(self.owner)
self.moderators.add(self.owner)
def clean(self):
if self.chat_type == self.ChatType.DM:
member_count = self.members.count() if self.pk else 0
if member_count > 2:
raise ValidationError("A DM chat cannot have more than 2 members.")
def __str__(self):
return f"Chat {self.id}"
class Message(SoftDeleteModel):
AUTHOR_FIELD = 'sender'
chat = models.ForeignKey(Chat, related_name='messages', on_delete=models.CASCADE)
sender = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='sent_messages'
)
#odpověď na jinou zprávu
reply_to = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='replies'
)
content = models.TextField(blank=True)
# --- TRACKING EDIT STATUS ---
# We add these so the frontend doesn't need to check MessageHistory table
is_edited = models.BooleanField(default=False)
edited_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def clean(self):
if self.chat_id and self.sender_id:
if not self.chat.members.filter(id=self.sender_id).exists():
raise ValidationError("Sender is not a member of this chat.")
# --- HELPER METHODS FOR WEBSOCKETS / VIEWS ---
def edit_content(self, new_text):
"""
Handles the complex logic of editing:
1. Checks if text actually changed.
2. Saves old text to History.
3. Updates current text and timestamps.
"""
if self.content == new_text:
return False # No change happened
# 1. Save History
MessageHistory.objects.create(
message=self,
old_content=self.content
)
# 2. Update Self
self.content = new_text
self.is_edited = True
self.edited_at = timezone.now()
self.save()
return True
def toggle_reaction(self, user, emoji):
"""
Handles Add/Remove/Switch logic.
Returns a tuple: (action, reaction_object)
action can be: 'added', 'removed', 'switched'
"""
try:
reaction = MessageReaction.objects.get(message=self, user=user)
if reaction.emoji == emoji:
reaction.delete()
return 'removed', None
else:
reaction.emoji = emoji
reaction.save()
return 'switched', reaction
except MessageReaction.DoesNotExist:
# Restore a stale soft-deleted record if one exists (avoids unique_together violation).
stale = MessageReaction.all_objects.filter(message=self, user=user).first()
if stale:
stale.emoji = emoji
stale.is_deleted = False
stale.deleted_at = None
stale.save()
return 'added', stale
reaction = MessageReaction.objects.create(message=self, user=user, emoji=emoji)
return 'added', reaction
def __str__(self):
return f"Message {self.id} from {self.sender}"
class MessageHistory(SoftDeleteModel):
message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name='edit_history'
)
old_content = models.TextField()
archived_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-archived_at']
class MessageReaction(SoftDeleteModel):
message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name='reactions'
)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
emoji = models.CharField(max_length=10)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["message", "user"],
condition=models.Q(is_deleted=False),
name="unique_active_reaction_per_user_message",
)
]
def __str__(self):
return f"{self.user} reacted {self.emoji}"
class MessageFile(SoftDeleteModel):
message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name='media_files'
)
file = models.FileField(upload_to='chat_uploads/%Y/%m/%d/')
media_type = models.CharField(max_length=20, choices=[
('IMAGE', 'Image'),
('VIDEO', 'Video'),
('FILE', 'File')
], default='FILE')
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Media {self.id} for Message {self.message.id}"
class ChatReadStatus(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='chat_read_statuses',
)
chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='read_statuses',
)
last_read_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'chat')
def __str__(self):
return f"{self.user} read {self.chat} at {self.last_read_at}"