added frontend for social + feed partiali working

This commit is contained in:
2026-05-18 02:25:47 +02:00
parent e1df55df0e
commit 202ce22102
88 changed files with 4236 additions and 737 deletions

View File

@@ -179,147 +179,3 @@ def _create_message(chat_id, sender, content, reply_to_id=None):
def _toggle_reaction(message_id, user, emoji):
message = Message.objects.get(pk=message_id)
return message.toggle_reaction(user, emoji)
class ChatConsumer(AsyncWebsocketConsumer):
# -- CONNECT --
async def connect(self):
self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]
self.chat_name = f"chat_{self.chat_id}"
user = self.scope["user"]
if not user.is_authenticated:
await self.close(code=4401) # unauthorized
return
#join chat group
async_to_sync(self.channel_layer.group_add)(
self.chat_name,
)
await self.accept()
# -- DISCONNECT --
async def disconnect(self, close_code):
async_to_sync(self.channel_layer.group_discard)(
self.chat_name
)
self.disconnect()
pass
# -- RECIVE --
async def receive(self, data):
if data["type"] == "new_chat_message":
message = data["message"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "chat.message", "message": message}
)
elif data["type"] == "new_reply_chat_message":
message = data["message"]
reply_to_id = data["reply_to_id"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "reply.chat.message", "message": message, "reply_to_id": reply_to_id}
)
elif data["type"] == "edit_chat_message":
message = data["message"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "edit.message", "message": message}
)
elif data["type"] == "delete_chat_message":
message_id = data["message_id"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "delete.message", "message_id": message_id}
)
elif data["type"] == "typing":
is_typing = data["is_typing"]
# Send typing status to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "typing.status", "user": self.scope["user"].username, "is_typing": is_typing}
)
elif data["type"] == "stop_typing":
# Send stop typing status to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "stop.typing", "user": self.scope["user"].username}
)
elif data["type"] == "reaction":
message_id = data["message_id"]
emoji = data["emoji"]
# Send reaction to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "message.reaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
)
elif data["type"] == "unreaction":
message_id = data["message_id"]
emoji = data["emoji"]
# Send unreaction to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "message.unreaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
)
else:
self.close(reason="Unsupported message type")
# -- CUSTOM METHODS --
def send_message_to_chat_group(self, event):
message = event["message"]
create_new_message()
self.send(text_data=json.dumps({"message": message}))
def edit_message_in_chat_group(self, event):
message = event["message"]
self.send(text_data=json.dumps({"message": message}))
# -- MESSAGES --
@database_sync_to_async
def create_new_message():
return None
@database_sync_to_async
def create_new_reply_message():
return None
@database_sync_to_async
def edit_message():
return None
@database_sync_to_async
def delete_message():
return None
# -- REACTIONS --
@database_sync_to_async
def react_to_message():
return None
@database_sync_to_async
def unreact_to_message():
return None

View File

@@ -8,6 +8,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, extend_schema_view
from vontor_cz.pagination import CreatedCursorPagination
from .models import Chat, Message, MessageFile
from .permissions import CanDeleteMessage, CanManageChat, IsChatMember, IsMessageSenderOnly
from .serializers import ChatMemberSerializer, ChatSerializer, MessageSendSerializer, MessageSerializer
@@ -155,10 +156,11 @@ class ChatViewSet(viewsets.ModelViewSet):
)
class MessageViewSet(viewsets.ModelViewSet):
serializer_class = MessageSerializer
pagination_class = CreatedCursorPagination
filterset_fields = ['chat', 'sender', 'reply_to']
search_fields = ['content']
ordering_fields = ['created_at']
ordering = ['created_at']
ordering = ['-created_at']
# Standard create is disabled — use POST /messages/send which handles files + WS broadcast
http_method_names = ['get', 'patch', 'put', 'delete', 'post', 'head', 'options']

View File

@@ -67,7 +67,18 @@ class PostContent(SoftDeleteModel):
def save(self, *args, **kwargs):
if self.file and not self.file._committed:
self.mime_type = mime.from_buffer(self.file.read(1024))
if mime is not None:
data = self.file.read(1024)
self.file.seek(0)
self.mime_type = mime.from_buffer(data)
else:
import mimetypes
content_type = getattr(self.file, 'content_type', None)
self.mime_type = (
content_type
or mimetypes.guess_type(self.file.name)[0]
or 'application/octet-stream'
)
return super().save(*args, **kwargs)

View File

@@ -1,7 +1,18 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from .models import Post, PostContent, PostVote
from social.hubs.serializers import TagsSerializer
User = get_user_model()
class AuthorMinimalSerializer(serializers.ModelSerializer):
avatar = serializers.ImageField(read_only=True)
class Meta:
model = User
fields = ['id', 'username', 'first_name', 'last_name', 'avatar']
class PostContentSerializer(serializers.ModelSerializer):
class Meta:
@@ -13,12 +24,32 @@ class PostContentSerializer(serializers.ModelSerializer):
class PostSerializer(serializers.ModelSerializer):
contents = PostContentSerializer(many=True, read_only=True)
tags = TagsSerializer(many=True, read_only=True)
author_detail = AuthorMinimalSerializer(source='author', read_only=True)
vote_score = serializers.SerializerMethodField()
user_vote = serializers.SerializerMethodField()
reply_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = Post
fields = ['id', 'content', 'created_at', 'updated_at', 'author', 'hub', 'reply_to', 'tags', 'contents']
fields = [
'id', 'content', 'created_at', 'updated_at',
'author', 'author_detail',
'hub', 'reply_to',
'tags', 'contents',
'vote_score', 'user_vote', 'reply_count',
]
read_only_fields = ['author', 'created_at', 'updated_at']
def get_vote_score(self, obj):
return sum(v.vote for v in obj.votes.all())
def get_user_vote(self, obj):
request = self.context.get('request')
if not request or not request.user.is_authenticated:
return 0
vote = obj.votes.filter(user=request.user).first()
return vote.vote if vote else 0
class PostVoteSerializer(serializers.ModelSerializer):
class Meta:

View File

@@ -1,14 +1,17 @@
from django.db.models import Count, Q
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.parsers import MultiPartParser
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, extend_schema_view
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from rest_framework.permissions import IsAuthenticated
from social.hubs.models import Tags
from .models import Post, PostVote
from vontor_cz.pagination import CreatedCursorPagination
from .models import Post, PostContent, PostVote
from .permissions import CanDeletePost, IsPostAuthorOnly
from .serializers import PostSerializer, PostVoteSerializer, TagAttachSerializer
from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer
# ---------------------------------------------------------------------------
@@ -61,7 +64,12 @@ class PostViewSet(viewsets.ModelViewSet):
return [IsAuthenticated()]
def get_queryset(self):
qs = Post.objects.select_related('author', 'hub').prefetch_related('tags', 'contents')
qs = (
Post.objects
.select_related('author', 'hub')
.prefetch_related('tags', 'contents', 'votes')
.annotate(reply_count=Count('replies', distinct=True))
)
hub_id = self.request.query_params.get('hub')
if hub_id:
qs = qs.filter(hub_id=hub_id)
@@ -70,6 +78,33 @@ class PostViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
serializer.save(author=self.request.user)
# ------------------------------------------------------------------
# Media upload action
# ------------------------------------------------------------------
@extend_schema(
tags=["posts"],
summary="Upload media to a post",
description="Attach an image or video file to a post. Only the post author can upload.",
request={'multipart/form-data': {
'type': 'object',
'properties': {'file': {'type': 'string', 'format': 'binary'}},
'required': ['file'],
}},
responses={201: PostContentSerializer},
)
@action(detail=True, methods=['post'], url_path='media', parser_classes=[MultiPartParser])
def upload_media(self, request, pk=None):
post = self.get_object()
if post.author != request.user:
raise PermissionDenied('Only the post author can upload media.')
file = request.FILES.get('file')
if not file:
raise ValidationError({'file': 'No file provided.'})
content = PostContent(post=post, file=file)
content.save()
return Response(PostContentSerializer(content).data, status=status.HTTP_201_CREATED)
# ------------------------------------------------------------------
# Tag attachment actions
# ------------------------------------------------------------------
@@ -132,6 +167,58 @@ class PostViewSet(viewsets.ModelViewSet):
post.tags.remove(tag)
return Response(PostSerializer(post, context={'request': request}).data)
# ------------------------------------------------------------------
# Feed action (cursor-paginated aggregated feed for the user)
# ------------------------------------------------------------------
@extend_schema(
tags=["posts"],
summary="Get the user's post feed",
description=(
"Returns a cursor-paginated stream of top-level posts (excluding replies) "
"aggregated from the user's joined hubs, public hubs, and hub-less posts. "
"Pass `feed_strategy` to switch between ranking algorithms (currently only "
"`recent` is implemented; reserved for future custom algorithms)."
),
parameters=[
OpenApiParameter(name='feed_strategy', required=False, type=str,
description="Algorithm key, default `recent`."),
OpenApiParameter(name='cursor', required=False, type=str,
description="Opaque pagination cursor."),
],
responses={200: PostSerializer(many=True)},
)
@action(detail=False, methods=['get'], url_path='feed')
def feed(self, request):
user = request.user
strategy = request.query_params.get('feed_strategy', 'recent')
base_qs = (
Post.objects
.select_related('author', 'hub')
.prefetch_related('tags', 'contents', 'votes')
.annotate(reply_count=Count('replies', distinct=True))
.filter(reply_to__isnull=True)
)
joined_hub_ids = list(user.hubs.values_list('id', flat=True)) if user.is_authenticated else []
visibility_filter = (
Q(hub__isnull=True)
| Q(hub__is_public=True)
| Q(hub_id__in=joined_hub_ids)
)
qs = base_qs.filter(visibility_filter).distinct()
if strategy == 'recent':
qs = qs.order_by('-created_at')
else:
qs = qs.order_by('-created_at')
paginator = CreatedCursorPagination()
page = paginator.paginate_queryset(qs, request, view=self)
ser = PostSerializer(page, many=True, context={'request': request})
return paginator.get_paginated_response(ser.data)
# ------------------------------------------------------------------
# Vote action
# ------------------------------------------------------------------
@@ -154,4 +241,3 @@ class PostViewSet(viewsets.ModelViewSet):
defaults={'vote': ser.validated_data['vote']},
)
return Response(PostVoteSerializer(vote_obj).data)