From 5280a87e8b13628692a3c0336205cccdba213c54 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Sun, 19 Apr 2026 01:36:03 +0200 Subject: [PATCH] updated to selfdelete inheritance --- backend/account/models.py | 27 +++++++++++++- backend/social/chat/models.py | 31 +++++++++++++--- backend/social/nápady_nebo_chybějicí.md | 13 +------ backend/social/pages/models.py | 43 ++++++++++++++++++++-- backend/social/posts/models.py | 38 +++++++++++++++++--- backend/vontor_cz/models.py | 47 +++++++++---------------- backend/vontor_cz/settings.py | 10 ++++-- 7 files changed, 151 insertions(+), 58 deletions(-) diff --git a/backend/account/models.py b/backend/account/models.py index 2d6fecc..9fcbc11 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -171,5 +171,30 @@ class CustomUser(SoftDeleteModel, AbstractUser): """Return the singleton anonymous user.""" User = CustomUser return User.objects.get(username="anonymous") - + + def has_blocked(self, user) -> bool: + return self.blocking.filter(blocked_user=user).exists() + + def is_blocked_by(self, user) -> bool: + return self.blocked_by.filter(blocker=user).exists() + + +class UserBlock(models.Model): + blocker = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='blocking' + ) + blocked_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='blocked_by' + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('blocker', 'blocked_user') + + def __str__(self): + return f"{self.blocker} blocked {self.blocked_user}" diff --git a/backend/social/chat/models.py b/backend/social/chat/models.py index 40085fc..6b80e6f 100644 --- a/backend/social/chat/models.py +++ b/backend/social/chat/models.py @@ -3,8 +3,21 @@ 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, + ) -class Chat(models.Model): owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, @@ -50,11 +63,19 @@ class Chat(models.Model): 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(models.Model): +class Message(SoftDeleteModel): + AUTHOR_FIELD = 'sender' + chat = models.ForeignKey(Chat, related_name='messages', on_delete=models.CASCADE) sender = models.ForeignKey( @@ -145,7 +166,7 @@ class Message(models.Model): return f"Message {self.id} from {self.sender}" -class MessageHistory(models.Model): +class MessageHistory(SoftDeleteModel): message = models.ForeignKey( Message, on_delete=models.CASCADE, @@ -158,7 +179,7 @@ class MessageHistory(models.Model): ordering = ['-archived_at'] -class MessageReaction(models.Model): +class MessageReaction(SoftDeleteModel): message = models.ForeignKey( Message, on_delete=models.CASCADE, @@ -175,7 +196,7 @@ class MessageReaction(models.Model): return f"{self.user} reacted {self.emoji}" -class MessageFile(models.Model): +class MessageFile(SoftDeleteModel): message = models.ForeignKey( Message, on_delete=models.CASCADE, diff --git a/backend/social/nápady_nebo_chybějicí.md b/backend/social/nápady_nebo_chybějicí.md index d6e5958..68b4bf7 100644 --- a/backend/social/nápady_nebo_chybějicí.md +++ b/backend/social/nápady_nebo_chybějicí.md @@ -1,17 +1,6 @@ # Social Feature Ideas -## Hub -- Hub visibility flag (`is_public`) — private/public hubs -- Hub owner transfer (current owner passes ownership to another member) - -## Chat -- Chat type field (DM vs group) — enforce 2-member limit on DMs -- Message soft-delete (`is_deleted` flag) — preserve reply context when a message is removed - ## Posts -- Post reactions/likes — equivalent of MessageReaction but for posts -- Post comments — a `Comment` model FK-ing to `Post` -- Upvote/downvote system for posts — reddit style voting with score and sorting by "hotness" ## Users -- User blocking — affects message/post visibility across all social features +- ~~User blocking — affects message/post visibility across all social features~~ ✓ done (`UserBlock` in account, `has_blocked`/`is_blocked_by` helpers on CustomUser) diff --git a/backend/social/pages/models.py b/backend/social/pages/models.py index cd81860..b6f8e1c 100644 --- a/backend/social/pages/models.py +++ b/backend/social/pages/models.py @@ -1,11 +1,15 @@ +import uuid from django.db import models from django.conf import settings from vontor_cz.custom_fields import WebPImageField +from vontor_cz.models import SoftDeleteModel # Create your models here. -class Hub(models.Model): +class Hub(SoftDeleteModel): + AUTHOR_FIELD = 'owner' + name = models.CharField(max_length=255, unique=True) description = models.TextField(blank=True, null=True) @@ -25,12 +29,45 @@ class Hub(models.Model): blank=True ) - #TODO: + is_public = models.BooleanField(default=True) + + + + transfer_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='pending_hub_transfers' + ) + transfer_token = models.UUIDField(null=True, blank=True, unique=True) + + def create_transfer(self, new_owner): + self.transfer_to = new_owner + self.transfer_token = uuid.uuid4() + self.save(update_fields=['transfer_to', 'transfer_token']) + + def verify_transfer(self, input_token, triggering_user): + """Requires uuid and triggering user must match the transfer_to field""" + if self.transfer_token and str(self.transfer_token) == str(input_token) and self.transfer_to == triggering_user: + self.owner = self.transfer_to + + self.transfer_to = None + self.transfer_token = None + + self.save(update_fields=['owner', 'transfer_to', 'transfer_token']) + return True + return raiseExceptions("Invalid transfer token or user does not match transfer_to field") + + def cancel_transfer(self): + self.transfer_to = None + self.transfer_token = None + self.save(update_fields=['transfer_to', 'transfer_token']) def __str__(self): return self.name -class HubPermission(models.Model): +class HubPermission(SoftDeleteModel): hub = models.ForeignKey(Hub, on_delete=models.CASCADE, related_name='moderators') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) diff --git a/backend/social/posts/models.py b/backend/social/posts/models.py index d0965ff..da1c759 100644 --- a/backend/social/posts/models.py +++ b/backend/social/posts/models.py @@ -1,12 +1,14 @@ from django.db import models - from django.conf import settings +from vontor_cz.models import SoftDeleteModel import magic mime = magic.Magic(mime=True) -class Post(models.Model): +class Post(SoftDeleteModel): + AUTHOR_FIELD = 'author' + content = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -23,11 +25,19 @@ class Post(models.Model): null=True, blank=True ) - + + reply_to = models.ForeignKey( + 'self', + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='replies' + ) + def __str__(self): return f"Post {self.id}" -class PostContent(models.Model): +class PostContent(SoftDeleteModel): post = models.ForeignKey(Post, related_name='contents', on_delete=models.CASCADE) mime_type = models.CharField(max_length=100) file = models.FileField(upload_to='post_contents/', null=True, blank=True) # For images/videos @@ -39,4 +49,22 @@ class PostContent(models.Model): def save(self, *args, **kwargs): if self.file and not self.file._committed: self.mime_type = mime.from_buffer(self.file.read(1024)) - return super().save(*args, **kwargs) \ No newline at end of file + return super().save(*args, **kwargs) + + +class PostVote(SoftDeleteModel): + class VoteChoice(models.IntegerChoices): + UP = 1, 'Upvote' + DOWN = -1, 'Downvote' + + post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='votes') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + vote = models.SmallIntegerField(choices=VoteChoice.choices) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('post', 'user') + + def __str__(self): + label = 'up' if self.vote == self.VoteChoice.UP else 'down' + return f"{self.user} voted {label} on Post {self.post_id}" \ No newline at end of file diff --git a/backend/vontor_cz/models.py b/backend/vontor_cz/models.py index a4e05d8..d80e9da 100644 --- a/backend/vontor_cz/models.py +++ b/backend/vontor_cz/models.py @@ -1,9 +1,24 @@ from django.db import models from django.utils import timezone + class ActiveManager(models.Manager): + class _QS(models.QuerySet): + def visible_to(self, user): + from account.models import UserBlock + author_field = getattr(self.model, 'AUTHOR_FIELD', 'author') + + blocked_ids = UserBlock.objects.filter(blocker=user).values_list('blocked_id', flat=True) + blocking_ids = UserBlock.objects.filter(blocked=user).values_list('blocker_id', flat=True) + + return ( + self.exclude(**{f'{author_field}__in': blocked_ids}) + .exclude(**{f'{author_field}__in': blocking_ids}) + ) + def get_queryset(self): - return super().get_queryset().filter(is_deleted=False) + return self._QS(self.model, using=self._db).filter(is_deleted=False) + class AllManager(models.Manager): def get_queryset(self): @@ -18,11 +33,6 @@ class SoftDeleteModel(models.Model): is_deleted = models.BooleanField(default=False) deleted_at = models.DateTimeField(null=True, blank=True) - def delete(self, using=None, keep_parents=False): - self.is_deleted = True - self.deleted_at = timezone.now() - self.save() - objects = ActiveManager() all_objects = AllManager() @@ -30,32 +40,9 @@ class SoftDeleteModel(models.Model): abstract = True def delete(self, *args, **kwargs): - # Soft delete self self.is_deleted = True self.deleted_at = timezone.now() self.save() def hard_delete(self, using=None, keep_parents=False): - super().delete(using=using, keep_parents=keep_parents) - - - -# SiteSettings model for managing site-wide settings -"""class SiteSettings(models.Model): - bank = models.CharField(max_length=100, blank=True) - support_email = models.EmailField(blank=True) - logo = models.ImageField(upload_to='settings/', blank=True, null=True) - - def __str__(self): - return "Site Settings" - - class Meta: - verbose_name = "Site Settings" - verbose_name_plural = "Site Settings" - - @classmethod - def get_solo(cls): - obj, created = cls.objects.get_or_create(id=1) - return obj - -""" + super().delete(using=using, keep_parents=keep_parents) \ No newline at end of file diff --git a/backend/vontor_cz/settings.py b/backend/vontor_cz/settings.py index 1c2a063..e94481d 100644 --- a/backend/vontor_cz/settings.py +++ b/backend/vontor_cz/settings.py @@ -317,7 +317,13 @@ REST_FRAMEWORK = { 'rest_framework.permissions.AllowAny', ), 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', - 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + # Default filter backends (ordering, search, filtering) + 'DEFAULT_FILTER_BACKENDS': [ + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.OrderingFilter', + 'rest_framework.filters.SearchFilter', + ], + # Enable default pagination so custom list actions (e.g., /orders/detail) paginate 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', @@ -328,7 +334,7 @@ REST_FRAMEWORK = { 'user': '2000/hour', # authenticated }, - 'EXCEPTION_HANDLER': 'trznice.utils.custom_exception_handler', + 'EXCEPTION_HANDLER': 'vontor_cz.utils.custom_exception_handler', } #--------------------------------END REST FRAMEWORK 🛠️-------------------------------------