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.
244 lines
6.9 KiB
Python
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}" |