gukgjzkgjhgjh
This commit is contained in:
68
backend/social/chat/chat app diagram.md
Normal file
68
backend/social/chat/chat app diagram.md
Normal file
@@ -0,0 +1,68 @@
|
||||
chat app diagram
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CLIENT (browser) │
|
||||
└────────────┬────────────────────────────┬───────────────────────┘
|
||||
│ WebSocket │ HTTP REST
|
||||
│ ws/chat/<id>/ │ /api/social/
|
||||
▼ ▼
|
||||
┌────────────────────────┐ ┌────────────────────────────────────┐
|
||||
│ ChatConsumer │ │ REST Views │
|
||||
│ (consumers.py) │ │ (views.py) │
|
||||
│ │ │ │
|
||||
│ connect() │ │ ChatViewSet │
|
||||
│ ├─ auth check │ │ ├─ list / retrieve (GET) │
|
||||
│ └─ membership check ──┼───┼──► IsChatMember │
|
||||
│ │ │ ├─ create (POST) │
|
||||
│ receive() │ │ ├─ update/partial (PATCH/PUT) │
|
||||
│ ├─ new_chat_message │ │ │ └─ CanManageChat │
|
||||
│ │ (text only) │ │ ├─ destroy (DELETE) │
|
||||
│ │ └─► _create_message │ │ └─ CanManageChat │
|
||||
│ │ (DB INSERT) │ │ └─ add/remove member & moderator │
|
||||
│ │ │ │ │
|
||||
│ ├─ new_reply_message │ │ MessageViewSet │
|
||||
│ │ (text only) │ │ ├─ list / retrieve (GET) │
|
||||
│ │ └─► _create_message │ │ └─ IsAuthenticated │
|
||||
│ │ (DB INSERT) │ │ ├─ send (POST) │
|
||||
│ │ │ │ │ ├─ IsAuthenticated │
|
||||
│ └─ reaction │ │ │ ├─ DB INSERT Message │
|
||||
│ └─► _toggle_reaction │ │ ├─ DB INSERT MessageFile(s) │
|
||||
│ (DB UPDATE/ │ │ │ └─► group_send(chat.msg) ─────┼──► WebSocket push
|
||||
│ DELETE) │ │ ├─ update/partial (PATCH/PUT) │
|
||||
│ │ │ │ ├─ IsMessageSenderOnly │
|
||||
│ typing / stop_typing │ │ │ ├─ message.edit_content() │
|
||||
│ └─► group_send only │ │ │ └─► group_send(edit.msg) ─────┼──► WebSocket push
|
||||
│ (no DB write) │ │ └─ destroy (DELETE) │
|
||||
│ │ │ ├─ CanDeleteMessage │
|
||||
│ │ │ └─► group_send(delete.msg) ───┼──► WebSocket push
|
||||
└────────────┬───────────┘ └──────────────┬─────────────────────┘
|
||||
│ │
|
||||
│ both use channel_layer │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Channel Layer (Redis) │
|
||||
│ group: "chat_{id}" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ group_send dispatches to all connected consumers
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ChatConsumer event handlers (push to each connected client) │
|
||||
│ chat_message · reply_chat_message · edit_message │
|
||||
│ delete_message · message_reaction · typing_status · stop_typing│
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────┐ ┌──────────────────────────┐
|
||||
│ Database writes summary │ │ When to use which path │
|
||||
│ │ │ │
|
||||
│ CREATE Message ← consumer │ │ WS ← text-only msg │
|
||||
│ (text only) │ │ HTTP← msg with files │
|
||||
│ CREATE Message ← view /send │ │ HTTP← edit message │
|
||||
│ (any) │ │ HTTP← delete message │
|
||||
│ CREATE MessageFile ← view/send │ │ WS ← reaction │
|
||||
│ UPDATE Message ← view │ │ WS ← typing indicator │
|
||||
│ DELETE Message ← view (soft) │ └──────────────────────────┘
|
||||
│ CREATE MessageReaction ← cons │
|
||||
│ UPDATE MessageReaction ← cons │
|
||||
│ DELETE MessageReaction ← cons │
|
||||
│ CREATE MessageHistory ← model │
|
||||
│ (auto on edit_content) │
|
||||
└──────────────────────────────────┘
|
||||
@@ -1,11 +1,185 @@
|
||||
# chat/consumers.py
|
||||
import json
|
||||
|
||||
from account.models import UserProfile
|
||||
|
||||
from channels.db import database_sync_to_async
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from asgiref.sync import sync_to_async, async_to_sync
|
||||
|
||||
from .models import Chat, Message
|
||||
|
||||
|
||||
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)
|
||||
return
|
||||
|
||||
is_member = await _is_chat_member(self.chat_id, user)
|
||||
if not is_member:
|
||||
await self.close(code=4403)
|
||||
return
|
||||
|
||||
await self.channel_layer.group_add(self.chat_name, self.channel_name)
|
||||
await self.accept()
|
||||
|
||||
# -- DISCONNECT --
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
await self.channel_layer.group_discard(self.chat_name, self.channel_name)
|
||||
|
||||
# -- RECEIVE --
|
||||
|
||||
async def receive(self, text_data):
|
||||
data = json.loads(text_data)
|
||||
user = self.scope["user"]
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "new_chat_message":
|
||||
message = await _create_message(
|
||||
chat_id=self.chat_id,
|
||||
sender=user,
|
||||
content=data["message"],
|
||||
)
|
||||
await self.channel_layer.group_send(self.chat_name, {
|
||||
"type": "chat.message",
|
||||
"message_id": message.id,
|
||||
"message": message.content,
|
||||
"sender": user.username,
|
||||
})
|
||||
|
||||
elif msg_type == "new_reply_chat_message":
|
||||
message = await _create_message(
|
||||
chat_id=self.chat_id,
|
||||
sender=user,
|
||||
content=data["message"],
|
||||
reply_to_id=data.get("reply_to_id"),
|
||||
)
|
||||
await self.channel_layer.group_send(self.chat_name, {
|
||||
"type": "reply.chat.message",
|
||||
"message_id": message.id,
|
||||
"message": message.content,
|
||||
"reply_to_id": data.get("reply_to_id"),
|
||||
"sender": user.username,
|
||||
})
|
||||
|
||||
elif msg_type == "reaction":
|
||||
action, reaction = await _toggle_reaction(
|
||||
message_id=data["message_id"],
|
||||
user=user,
|
||||
emoji=data["emoji"],
|
||||
)
|
||||
await self.channel_layer.group_send(self.chat_name, {
|
||||
"type": "message.reaction",
|
||||
"message_id": data["message_id"],
|
||||
"emoji": data["emoji"],
|
||||
"user": user.username,
|
||||
"action": action, # 'added' | 'removed' | 'switched'
|
||||
})
|
||||
|
||||
elif msg_type == "typing":
|
||||
await self.channel_layer.group_send(self.chat_name, {
|
||||
"type": "typing.status",
|
||||
"user": user.username,
|
||||
"is_typing": data.get("is_typing", True),
|
||||
})
|
||||
|
||||
elif msg_type == "stop_typing":
|
||||
await self.channel_layer.group_send(self.chat_name, {
|
||||
"type": "stop.typing",
|
||||
"user": user.username,
|
||||
})
|
||||
|
||||
else:
|
||||
await self.send(text_data=json.dumps({"error": "Unsupported message type."}))
|
||||
|
||||
# -- GROUP EVENT HANDLERS --
|
||||
# These are called by the channel layer when another part of the system
|
||||
# (consumer or view) calls group_send.
|
||||
|
||||
async def chat_message(self, event):
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "new_chat_message",
|
||||
"message_id": event["message_id"],
|
||||
"message": event["message"],
|
||||
"sender": event["sender"],
|
||||
}))
|
||||
|
||||
async def reply_chat_message(self, event):
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "new_reply_chat_message",
|
||||
"message_id": event["message_id"],
|
||||
"message": event["message"],
|
||||
"reply_to_id": event["reply_to_id"],
|
||||
"sender": event["sender"],
|
||||
}))
|
||||
|
||||
async def edit_message(self, event):
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "edit_chat_message",
|
||||
"message_id": event["message_id"],
|
||||
"content": event["content"],
|
||||
"is_edited": event.get("is_edited", True),
|
||||
}))
|
||||
|
||||
async def delete_message(self, event):
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "delete_chat_message",
|
||||
"message_id": event["message_id"],
|
||||
}))
|
||||
|
||||
async def message_reaction(self, event):
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "reaction",
|
||||
"message_id": event["message_id"],
|
||||
"emoji": event["emoji"],
|
||||
"user": event["user"],
|
||||
"action": event["action"],
|
||||
}))
|
||||
|
||||
async def typing_status(self, event):
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "typing",
|
||||
"user": event["user"],
|
||||
"is_typing": event["is_typing"],
|
||||
}))
|
||||
|
||||
async def stop_typing(self, event):
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "stop_typing",
|
||||
"user": event["user"],
|
||||
}))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB helpers (run in thread pool via database_sync_to_async)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@database_sync_to_async
|
||||
def _is_chat_member(chat_id, user):
|
||||
return Chat.objects.filter(pk=chat_id, members=user).exists()
|
||||
|
||||
|
||||
@database_sync_to_async
|
||||
def _create_message(chat_id, sender, content, reply_to_id=None):
|
||||
return Message.objects.create(
|
||||
chat_id=chat_id,
|
||||
sender=sender,
|
||||
content=content,
|
||||
reply_to_id=reply_to_id,
|
||||
)
|
||||
|
||||
|
||||
@database_sync_to_async
|
||||
def _toggle_reaction(message_id, user, emoji):
|
||||
message = Message.objects.get(pk=message_id)
|
||||
return message.toggle_reaction(user, emoji)
|
||||
|
||||
|
||||
|
||||
class ChatConsumer(AsyncWebsocketConsumer):
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-19 21:51
|
||||
|
||||
import django.db.models.deletion
|
||||
import vontor_cz.custom_fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('chat', '0001_initial'),
|
||||
('hubs', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chat',
|
||||
name='banner',
|
||||
field=vontor_cz.custom_fields.WebPImageField(blank=True, null=True, upload_to='chat_banners/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chat',
|
||||
name='chat_type',
|
||||
field=models.CharField(choices=[('DM', 'Direct Message'), ('GROUP', 'Group')], default='GROUP', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chat',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chat',
|
||||
name='hub',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='hubs.hub'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chat',
|
||||
name='icon',
|
||||
field=vontor_cz.custom_fields.WebPImageField(blank=True, null=True, upload_to='chat_icons/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chat',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chat',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='messagefile',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='messagefile',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='messagehistory',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='messagehistory',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='messagereaction',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='messagereaction',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='message',
|
||||
name='sender',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_messages', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -43,7 +43,7 @@ class Chat(SoftDeleteModel):
|
||||
)
|
||||
|
||||
hub = models.ForeignKey(
|
||||
'pages.Hub',
|
||||
'hubs.Hub',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True
|
||||
|
||||
65
backend/social/chat/permissions.py
Normal file
65
backend/social/chat/permissions.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from rest_framework.permissions import IsAuthenticated, SAFE_METHODS
|
||||
|
||||
|
||||
class IsChatMember(IsAuthenticated):
|
||||
"""
|
||||
View-level: must be authenticated (inherited).
|
||||
Object-level: safe methods require chat membership; unsafe require membership too.
|
||||
Used for reading messages and listing chat details.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return request.user.is_superuser or obj.members.filter(pk=request.user.pk).exists()
|
||||
|
||||
|
||||
class CanManageChat(IsAuthenticated):
|
||||
"""
|
||||
View-level: must be authenticated (inherited).
|
||||
Object-level unsafe: chat owner, moderator, or superuser.
|
||||
Used for editing/deleting the chat itself and managing members.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
user = request.user
|
||||
return (
|
||||
user.is_superuser
|
||||
or obj.owner == user
|
||||
or obj.moderators.filter(pk=user.pk).exists()
|
||||
)
|
||||
|
||||
|
||||
class IsMessageSenderOnly(IsAuthenticated):
|
||||
"""
|
||||
View-level: must be authenticated (inherited).
|
||||
Object-level unsafe: message sender only.
|
||||
Used for editing messages.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return obj.sender == request.user
|
||||
|
||||
|
||||
class CanDeleteMessage(IsAuthenticated):
|
||||
"""
|
||||
View-level: must be authenticated (inherited).
|
||||
Object-level DELETE:
|
||||
- Message sender
|
||||
- Superuser
|
||||
- Chat owner
|
||||
- Chat moderator
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
user = request.user
|
||||
if obj.sender == user or user.is_superuser:
|
||||
return True
|
||||
return (
|
||||
obj.chat.owner == user
|
||||
or obj.chat.moderators.filter(pk=user.pk).exists()
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Chat, Message, MessageFile, MessageHistory, MessageReaction
|
||||
|
||||
|
||||
class MessageFileSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MessageFile
|
||||
fields = ['id', 'file', 'media_type', 'uploaded_at']
|
||||
read_only_fields = ['uploaded_at']
|
||||
|
||||
|
||||
class MessageReactionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MessageReaction
|
||||
fields = ['id', 'user', 'emoji', 'created_at']
|
||||
read_only_fields = ['user', 'created_at']
|
||||
|
||||
|
||||
class MessageHistorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MessageHistory
|
||||
fields = ['id', 'old_content', 'archived_at']
|
||||
read_only_fields = ['archived_at']
|
||||
|
||||
|
||||
class MessageSerializer(serializers.ModelSerializer):
|
||||
media_files = MessageFileSerializer(many=True, read_only=True)
|
||||
reactions = MessageReactionSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = [
|
||||
'id', 'chat', 'sender', 'reply_to',
|
||||
'content', 'is_edited', 'edited_at',
|
||||
'created_at', 'updated_at',
|
||||
'media_files', 'reactions',
|
||||
]
|
||||
read_only_fields = ['sender', 'chat', 'reply_to', 'is_edited', 'edited_at', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class MessageSendSerializer(serializers.Serializer):
|
||||
"""Used for the HTTP send endpoint (text + optional files)."""
|
||||
chat = serializers.PrimaryKeyRelatedField(queryset=Chat.objects.all())
|
||||
content = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
reply_to = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Message.objects.all(), required=False, allow_null=True
|
||||
)
|
||||
# files come via request.FILES - declared here for schema documentation only
|
||||
files = serializers.ListField(
|
||||
child=serializers.FileField(allow_empty_file=False, use_url=False),
|
||||
required=False,
|
||||
default=list,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
if not attrs.get('content') and not attrs.get('files'):
|
||||
raise serializers.ValidationError('A message must have content or at least one file.')
|
||||
return attrs
|
||||
|
||||
|
||||
class ChatSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Chat
|
||||
fields = [
|
||||
'id', 'chat_type', 'owner', 'name',
|
||||
'icon', 'banner', 'members', 'moderators',
|
||||
'hub', 'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['owner', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class ChatMemberSerializer(serializers.Serializer):
|
||||
user_id = serializers.IntegerField(help_text='PK of the user to add or remove.')
|
||||
|
||||
8
backend/social/chat/urls.py
Normal file
8
backend/social/chat/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ChatViewSet, MessageViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('chats', ChatViewSet, basename='chat')
|
||||
router.register('messages', MessageViewSet, basename='message')
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -1,25 +1,252 @@
|
||||
from django.shortcuts import render
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
# Create your views here.
|
||||
from .models import Chat, Message, MessageFile
|
||||
from .permissions import CanDeleteMessage, CanManageChat, IsChatMember, IsMessageSenderOnly
|
||||
from .serializers import ChatMemberSerializer, ChatSerializer, MessageSendSerializer, MessageSerializer
|
||||
|
||||
|
||||
def get_users_chats(request):
|
||||
return None
|
||||
def _broadcast(chat_id, payload):
|
||||
"""Push a payload to the channel group for a chat from sync code."""
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(f"chat_{chat_id}", payload)
|
||||
|
||||
def create_chat(request):
|
||||
return None
|
||||
|
||||
def invite_user_to_chat(request, chat_id: int, user_ids: list):
|
||||
return None
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat ViewSet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def delete_chat(request, chat_id: int):
|
||||
return None
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=["chat"], summary="List chats", description="Returns all chats the user is a member of. Superusers see all."),
|
||||
retrieve=extend_schema(tags=["chat"], summary="Retrieve a chat"),
|
||||
create=extend_schema(tags=["chat"], summary="Create a chat", description="Owner is set to the requesting user automatically."),
|
||||
update=extend_schema(tags=["chat"], summary="Replace a chat", description="Owner, moderator, or superuser only."),
|
||||
partial_update=extend_schema(tags=["chat"], summary="Update a chat", description="Owner, moderator, or superuser only."),
|
||||
destroy=extend_schema(tags=["chat"], summary="Delete a chat", description="Soft-deletes. Owner or superuser only."),
|
||||
)
|
||||
class ChatViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ChatSerializer
|
||||
filterset_fields = ['chat_type', 'hub']
|
||||
search_fields = ['name']
|
||||
ordering_fields = ['created_at', 'name']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def leave_chat(request, chat_id: int):
|
||||
return None
|
||||
def get_permissions(self):
|
||||
if self.action in ('update', 'partial_update', 'destroy',
|
||||
'add_member', 'remove_member', 'add_moderator', 'remove_moderator'):
|
||||
return [CanManageChat()]
|
||||
return [IsChatMember()]
|
||||
|
||||
def edit_chat(request, chat_object):
|
||||
return None
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if user.is_superuser:
|
||||
return Chat.objects.all()
|
||||
return Chat.objects.filter(members=user)
|
||||
|
||||
def get_chat_messages(request, chat_id: int, limit: int = 50, offset: int = 0):
|
||||
return None
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(owner=self.request.user)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Member management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["chat"],
|
||||
summary="Add a member",
|
||||
description="Owner, moderator, or superuser only.",
|
||||
request=ChatMemberSerializer,
|
||||
responses={200: ChatSerializer},
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='members/add')
|
||||
def add_member(self, request, pk=None):
|
||||
chat = self.get_object()
|
||||
ser = ChatMemberSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
User = get_user_model()
|
||||
try:
|
||||
user = User.objects.get(pk=ser.validated_data['user_id'])
|
||||
except User.DoesNotExist:
|
||||
return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
chat.members.add(user)
|
||||
return Response(ChatSerializer(chat, context={'request': request}).data)
|
||||
|
||||
@extend_schema(
|
||||
tags=["chat"],
|
||||
summary="Remove a member",
|
||||
description="Owner, moderator, or superuser only.",
|
||||
request=ChatMemberSerializer,
|
||||
responses={204: None},
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='members/remove')
|
||||
def remove_member(self, request, pk=None):
|
||||
chat = self.get_object()
|
||||
ser = ChatMemberSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
User = get_user_model()
|
||||
try:
|
||||
user = User.objects.get(pk=ser.validated_data['user_id'])
|
||||
except User.DoesNotExist:
|
||||
return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
chat.members.remove(user)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@extend_schema(
|
||||
tags=["chat"],
|
||||
summary="Add a moderator",
|
||||
description="Owner or superuser only.",
|
||||
request=ChatMemberSerializer,
|
||||
responses={200: ChatSerializer},
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='moderators/add')
|
||||
def add_moderator(self, request, pk=None):
|
||||
chat = self.get_object()
|
||||
if not (chat.owner == request.user or request.user.is_superuser):
|
||||
raise PermissionDenied('Only the chat owner or superuser can add moderators.')
|
||||
ser = ChatMemberSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
User = get_user_model()
|
||||
try:
|
||||
user = User.objects.get(pk=ser.validated_data['user_id'])
|
||||
except User.DoesNotExist:
|
||||
return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
chat.moderators.add(user)
|
||||
return Response(ChatSerializer(chat, context={'request': request}).data)
|
||||
|
||||
@extend_schema(
|
||||
tags=["chat"],
|
||||
summary="Remove a moderator",
|
||||
description="Owner or superuser only.",
|
||||
request=ChatMemberSerializer,
|
||||
responses={204: None},
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='moderators/remove')
|
||||
def remove_moderator(self, request, pk=None):
|
||||
chat = self.get_object()
|
||||
if not (chat.owner == request.user or request.user.is_superuser):
|
||||
raise PermissionDenied('Only the chat owner or superuser can remove moderators.')
|
||||
ser = ChatMemberSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
User = get_user_model()
|
||||
try:
|
||||
user = User.objects.get(pk=ser.validated_data['user_id'])
|
||||
except User.DoesNotExist:
|
||||
return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
chat.moderators.remove(user)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message ViewSet (read + edit + delete only — creation is via WebSocket)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=["chat"], summary="List messages", description="Filter by `?chat=<id>`. Chat members only."),
|
||||
retrieve=extend_schema(tags=["chat"], summary="Retrieve a message"),
|
||||
partial_update=extend_schema(tags=["chat"], summary="Edit a message", description="Sender only. Broadcasts `edit.message` to the chat channel group in real time."),
|
||||
update=extend_schema(tags=["chat"], summary="Replace a message", description="Sender only."),
|
||||
destroy=extend_schema(tags=["chat"], summary="Delete a message", description="Sender, chat owner, moderator, or superuser. Broadcasts `delete.message` to the chat channel group in real time."),
|
||||
)
|
||||
class MessageViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = MessageSerializer
|
||||
filterset_fields = ['chat', 'sender', 'reply_to']
|
||||
search_fields = ['content']
|
||||
ordering_fields = ['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']
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == 'destroy':
|
||||
return [CanDeleteMessage()]
|
||||
if self.action in ('update', 'partial_update'):
|
||||
return [IsMessageSenderOnly()]
|
||||
if self.action == 'send':
|
||||
return [IsAuthenticated()]
|
||||
# list, retrieve — any authenticated user (membership enforced by get_queryset)
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = Message.objects.select_related('sender', 'chat').prefetch_related('media_files', 'reactions')
|
||||
if user.is_superuser:
|
||||
return qs
|
||||
# Only messages from chats the user is a member of
|
||||
return qs.filter(chat__members=user)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
message = serializer.instance
|
||||
new_content = serializer.validated_data.get('content', message.content)
|
||||
changed = message.edit_content(new_content)
|
||||
if changed:
|
||||
_broadcast(message.chat_id, {
|
||||
'type': 'edit.message',
|
||||
'message_id': message.id,
|
||||
'content': message.content,
|
||||
'is_edited': True,
|
||||
})
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
chat_id = instance.chat_id
|
||||
message_id = instance.id
|
||||
instance.delete()
|
||||
_broadcast(chat_id, {
|
||||
'type': 'delete.message',
|
||||
'message_id': message_id,
|
||||
})
|
||||
|
||||
@extend_schema(
|
||||
tags=["chat"],
|
||||
summary="Send a message",
|
||||
description=(
|
||||
"Creates a message with optional file attachments and broadcasts it to all "
|
||||
"connected WebSocket clients in the chat. Use this for all messages that include "
|
||||
"files, and optionally for text-only messages too. Chat members only."
|
||||
),
|
||||
request=MessageSendSerializer,
|
||||
responses={201: MessageSerializer},
|
||||
)
|
||||
@action(detail=False, methods=['post'], url_path='send')
|
||||
def send(self, request):
|
||||
ser = MessageSendSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
|
||||
chat = ser.validated_data['chat']
|
||||
if not request.user.is_superuser and not chat.members.filter(pk=request.user.pk).exists():
|
||||
raise PermissionDenied('You are not a member of this chat.')
|
||||
|
||||
message = Message.objects.create(
|
||||
chat=chat,
|
||||
sender=request.user,
|
||||
content=ser.validated_data.get('content', ''),
|
||||
reply_to=ser.validated_data.get('reply_to'),
|
||||
)
|
||||
|
||||
for f in request.FILES.getlist('files'):
|
||||
ct = getattr(f, 'content_type', '')
|
||||
if ct.startswith('image/'):
|
||||
mt = 'IMAGE'
|
||||
elif ct.startswith('video/'):
|
||||
mt = 'VIDEO'
|
||||
else:
|
||||
mt = 'FILE'
|
||||
MessageFile.objects.create(message=message, file=f, media_type=mt)
|
||||
|
||||
_broadcast(chat.id, {
|
||||
'type': 'chat.message',
|
||||
'message_id': message.id,
|
||||
'message': message.content,
|
||||
'sender': request.user.username,
|
||||
'has_files': message.media_files.exists(),
|
||||
})
|
||||
|
||||
return Response(
|
||||
MessageSerializer(message, context={'request': request}).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PagesConfig(AppConfig):
|
||||
class HubsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'pages'
|
||||
name = 'social.hubs'
|
||||
|
||||
label = "hubs"
|
||||
73
backend/social/hubs/migrations/0001_initial.py
Normal file
73
backend/social/hubs/migrations/0001_initial.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-19 21:51
|
||||
|
||||
import django.db.models.deletion
|
||||
import vontor_cz.custom_fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Hub',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('icon', vontor_cz.custom_fields.WebPImageField(blank=True, null=True, upload_to='hub_icons/')),
|
||||
('banner', vontor_cz.custom_fields.WebPImageField(blank=True, null=True, upload_to='hub_banners/')),
|
||||
('is_public', models.BooleanField(default=True)),
|
||||
('transfer_token', models.UUIDField(blank=True, null=True, unique=True)),
|
||||
('members', models.ManyToManyField(blank=True, related_name='hubs', to=settings.AUTH_USER_MODEL)),
|
||||
('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_hubs', to=settings.AUTH_USER_MODEL)),
|
||||
('transfer_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pending_hub_transfers', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Tags',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('name', models.CharField(max_length=50)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('color', models.CharField(default='#000000', max_length=7)),
|
||||
('hub', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='hubs.hub')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HubPermission',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('changing_name', models.BooleanField(default=False)),
|
||||
('changing_description', models.BooleanField(default=False)),
|
||||
('changing_icon', models.BooleanField(default=False)),
|
||||
('changing_banner', models.BooleanField(default=False)),
|
||||
('managing_members', models.BooleanField(default=False)),
|
||||
('managing_posts', models.BooleanField(default=False)),
|
||||
('managing_chats', models.BooleanField(default=False)),
|
||||
('hub', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderators', to='hubs.hub')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('hub', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/social/hubs/migrations/__init__.py
Normal file
0
backend/social/hubs/migrations/__init__.py
Normal file
@@ -1,9 +1,12 @@
|
||||
from turtle import color
|
||||
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
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Create your models here.
|
||||
|
||||
|
||||
@@ -57,13 +60,16 @@ class Hub(SoftDeleteModel):
|
||||
|
||||
self.save(update_fields=['owner', 'transfer_to', 'transfer_token'])
|
||||
return True
|
||||
return raiseExceptions("Invalid transfer token or user does not match transfer_to field")
|
||||
raise ValidationError("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 get_all_tags(self):
|
||||
return Tags.objects.filter(hub_id=self.id)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -84,4 +90,13 @@ class HubPermission(SoftDeleteModel):
|
||||
unique_together = ('hub', 'user')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} moderates {self.hub}"
|
||||
return f"{self.user} moderates {self.hub}"
|
||||
|
||||
|
||||
class Tags(SoftDeleteModel):
|
||||
hub = models.ForeignKey(Hub, on_delete=models.CASCADE, related_name='tags')
|
||||
name = models.CharField(max_length=50)
|
||||
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
color = models.CharField(max_length=7, default='#000000') # Hex color code
|
||||
53
backend/social/hubs/permissions.py
Normal file
53
backend/social/hubs/permissions.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from rest_framework.permissions import IsAuthenticated, SAFE_METHODS
|
||||
|
||||
|
||||
class CanEditHub(IsAuthenticated):
|
||||
"""
|
||||
Hub object-level permission.
|
||||
- View-level: must be authenticated (inherited).
|
||||
- Object-level unsafe: hub owner, superuser, or any moderator
|
||||
(field-level restrictions enforced in HubSerializer).
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
|
||||
user = request.user
|
||||
if obj.owner == user or user.is_superuser:
|
||||
return True
|
||||
|
||||
return obj.moderators.filter(user=user).exists()
|
||||
|
||||
|
||||
class IsHubOwnerOrSuperuser(IsAuthenticated):
|
||||
"""
|
||||
For objects with a .hub FK (e.g. HubPermission).
|
||||
- View-level: must be authenticated (inherited).
|
||||
- Object-level unsafe: hub owner or superuser only.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
|
||||
return request.user.is_superuser or obj.hub.owner == request.user
|
||||
|
||||
|
||||
class CanManageHubTags(IsAuthenticated):
|
||||
"""
|
||||
For Tags (navigates via obj.hub).
|
||||
- View-level: must be authenticated (inherited).
|
||||
- Object-level unsafe: hub owner, superuser, or moderator with managing_posts=True.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
|
||||
user = request.user
|
||||
hub = obj.hub
|
||||
if user.is_superuser or hub.owner == user:
|
||||
return True
|
||||
|
||||
return hub.moderators.filter(user=user, managing_posts=True).exists()
|
||||
81
backend/social/hubs/serializers.py
Normal file
81
backend/social/hubs/serializers.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Hub, HubPermission, Tags
|
||||
|
||||
|
||||
# Maps Hub fields -> the HubPermission flag that allows changing them
|
||||
FIELD_PERMISSION_MAP = {
|
||||
'name': 'changing_name',
|
||||
'description': 'changing_description',
|
||||
'icon': 'changing_icon',
|
||||
'banner': 'changing_banner',
|
||||
'members': 'managing_members',
|
||||
}
|
||||
|
||||
|
||||
class TransferInitSerializer(serializers.Serializer):
|
||||
user_id = serializers.IntegerField(help_text="PK of the user to transfer ownership to.")
|
||||
|
||||
|
||||
class TransferVerifySerializer(serializers.Serializer):
|
||||
token = serializers.UUIDField(help_text="Transfer token sent to the new owner.")
|
||||
|
||||
|
||||
class TagsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Tags
|
||||
fields = ['id', 'name', 'description', 'color']
|
||||
|
||||
|
||||
class HubPermissionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = HubPermission
|
||||
fields = [
|
||||
'id', 'user',
|
||||
'changing_name', 'changing_description', 'changing_icon', 'changing_banner',
|
||||
'managing_members', 'managing_posts', 'managing_chats',
|
||||
]
|
||||
|
||||
|
||||
class HubSerializer(serializers.ModelSerializer):
|
||||
tags = TagsSerializer(many=True, read_only=True)
|
||||
moderators = HubPermissionSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Hub
|
||||
fields = [
|
||||
'id', 'name', 'description', 'owner',
|
||||
'icon', 'banner', 'members', 'is_public',
|
||||
'tags', 'moderators',
|
||||
]
|
||||
read_only_fields = ['owner']
|
||||
|
||||
def validate(self, attrs):
|
||||
hub = self.instance # None on create — no restrictions needed
|
||||
if hub is None:
|
||||
return attrs
|
||||
|
||||
request = self.context.get('request')
|
||||
if request is None:
|
||||
return attrs
|
||||
|
||||
user = request.user
|
||||
|
||||
# Owner and superuser bypass field-level checks
|
||||
if hub.owner == user or user.is_superuser:
|
||||
return attrs
|
||||
|
||||
# Moderator: enforce per-field permission flags
|
||||
try:
|
||||
perm = hub.moderators.get(user=user)
|
||||
|
||||
except HubPermission.DoesNotExist:
|
||||
raise serializers.ValidationError('You do not have permission to edit this hub.')
|
||||
|
||||
for field in attrs:
|
||||
flag = FIELD_PERMISSION_MAP.get(field)
|
||||
if flag and not getattr(perm, flag, False):
|
||||
raise serializers.ValidationError(
|
||||
{field: 'You do not have permission to change this field.'}
|
||||
)
|
||||
|
||||
return attrs
|
||||
9
backend/social/hubs/urls.py
Normal file
9
backend/social/hubs/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import HubViewSet, HubPermissionViewSet, TagsViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('', HubViewSet, basename='hub')
|
||||
router.register('moderators', HubPermissionViewSet, basename='hub-moderator')
|
||||
router.register('tags', TagsViewSet, basename='hub-tag')
|
||||
|
||||
urlpatterns = router.urls
|
||||
294
backend/social/hubs/views.py
Normal file
294
backend/social/hubs/views.py
Normal file
@@ -0,0 +1,294 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from .models import Hub, HubPermission, Tags
|
||||
from .permissions import CanEditHub, CanManageHubTags, IsHubOwnerOrSuperuser
|
||||
from .serializers import HubPermissionSerializer, HubSerializer, TagsSerializer, TransferInitSerializer, TransferVerifySerializer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hub ViewSet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="List hubs",
|
||||
description=(
|
||||
"Returns all public hubs. Authenticated users also see hubs they are members of. "
|
||||
"Admins see all hubs regardless of visibility."
|
||||
),
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Retrieve a hub",
|
||||
),
|
||||
create=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Create a hub",
|
||||
description="Creates a new hub. The requesting user is automatically set as the owner.",
|
||||
),
|
||||
update=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Replace a hub",
|
||||
description=(
|
||||
"Full update. Restricted to the owner, site admin, or moderators "
|
||||
"(per-field permission flags are enforced)."
|
||||
),
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Update a hub",
|
||||
description=(
|
||||
"Partial update. Restricted to the owner, site admin, or moderators "
|
||||
"(per-field permission flags are enforced)."
|
||||
),
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Delete a hub",
|
||||
description="Soft-deletes the hub. Owner or admin only.",
|
||||
),
|
||||
)
|
||||
class HubViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = HubSerializer
|
||||
permission_classes = [CanEditHub]
|
||||
filterset_fields = ['is_public', 'owner']
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name']
|
||||
ordering = ['name']
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if user.is_superuser:
|
||||
return Hub.objects.all()
|
||||
return (
|
||||
Hub.objects.filter(is_public=True) | Hub.objects.filter(members=user)
|
||||
).distinct()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(owner=self.request.user)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Membership actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Join a hub",
|
||||
description="Adds the authenticated user as a member. Private hubs reject this request.",
|
||||
request=None,
|
||||
responses={200: HubSerializer},
|
||||
)
|
||||
@action(detail=True, methods=['post'])
|
||||
def join(self, request, pk=None):
|
||||
hub = self.get_object()
|
||||
if not hub.is_public and not (hub.owner == request.user or request.user.is_superuser):
|
||||
return Response(
|
||||
{'detail': 'This hub is private.'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
hub.members.add(request.user)
|
||||
return Response(HubSerializer(hub, context={'request': request}).data)
|
||||
|
||||
@extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Leave a hub",
|
||||
description="Removes the authenticated user from the hub's members.",
|
||||
request=None,
|
||||
responses={204: None},
|
||||
)
|
||||
@action(detail=True, methods=['post'])
|
||||
def leave(self, request, pk=None):
|
||||
hub = self.get_object()
|
||||
hub.members.remove(request.user)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Ownership transfer actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Initiate ownership transfer",
|
||||
description=(
|
||||
"Generates a transfer token and records the intended new owner. "
|
||||
"Only the current hub owner can initiate. The recipient must call "
|
||||
"`transfer/verify` with the token to complete the transfer."
|
||||
),
|
||||
request=TransferInitSerializer,
|
||||
responses={200: TransferInitSerializer},
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='transfer/initiate')
|
||||
def initiate_transfer(self, request, pk=None):
|
||||
hub = self.get_object()
|
||||
if hub.owner != request.user:
|
||||
return Response(
|
||||
{'detail': 'Only the hub owner can initiate a transfer.'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
ser = TransferInitSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
|
||||
User = get_user_model()
|
||||
try:
|
||||
new_owner = User.objects.get(pk=ser.validated_data['user_id'])
|
||||
except User.DoesNotExist:
|
||||
return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
hub.create_transfer(new_owner)
|
||||
transfer_url = f"{settings.FRONTEND_URL}/hubs/{hub.pk}/transfer/verify?token={hub.transfer_token}"
|
||||
return Response({'detail': f'Transfer initiated to {new_owner}.', 'transfer_url': transfer_url})
|
||||
|
||||
@extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Verify ownership transfer",
|
||||
description=(
|
||||
"Completes the transfer when the intended new owner supplies the correct token. "
|
||||
"Must be called by the transfer recipient."
|
||||
),
|
||||
request=TransferVerifySerializer,
|
||||
responses={200: HubSerializer},
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='transfer/verify')
|
||||
def verify_transfer(self, request, pk=None):
|
||||
hub = self.get_object()
|
||||
ser = TransferVerifySerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
try:
|
||||
hub.verify_transfer(ser.validated_data['token'], request.user)
|
||||
except Exception as exc:
|
||||
return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(HubSerializer(hub, context={'request': request}).data)
|
||||
|
||||
@extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Cancel ownership transfer",
|
||||
description="Cancels a pending transfer, clearing the token and recipient. Owner or admin only.",
|
||||
request=None,
|
||||
responses={200: None},
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='transfer/cancel')
|
||||
def cancel_transfer(self, request, pk=None):
|
||||
hub = self.get_object()
|
||||
if not (hub.owner == request.user or request.user.is_superuser):
|
||||
raise PermissionDenied('Only the hub owner or superuser can cancel a transfer.')
|
||||
hub.cancel_transfer()
|
||||
return Response({'detail': 'Transfer cancelled.'})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hub Moderator (HubPermission) ViewSet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="List hub moderators",
|
||||
description="Returns all moderators and their permission flags for a given hub. Pass `hub` as a query param.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Retrieve a hub moderator",
|
||||
),
|
||||
create=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Add a hub moderator",
|
||||
description="Grants a user moderator permissions on the hub. Owner or admin only.",
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Update moderator permissions",
|
||||
description="Updates one or more permission flags for a moderator. Owner or admin only.",
|
||||
),
|
||||
update=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Replace moderator permissions",
|
||||
description="Replaces all permission flags for a moderator. Owner or admin only.",
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Remove a hub moderator",
|
||||
description="Revokes all moderator permissions from a user. Owner or admin only.",
|
||||
),
|
||||
)
|
||||
class HubPermissionViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = HubPermissionSerializer
|
||||
permission_classes = [IsHubOwnerOrSuperuser]
|
||||
filterset_fields = ['user', 'changing_name', 'changing_description', 'changing_icon', 'changing_banner', 'managing_members', 'managing_posts', 'managing_chats']
|
||||
|
||||
def _get_hub(self):
|
||||
hub_id = self.kwargs.get('hub_pk') or self.request.query_params.get('hub')
|
||||
return Hub.objects.get(pk=hub_id)
|
||||
|
||||
def get_queryset(self):
|
||||
return HubPermission.objects.filter(hub=self._get_hub())
|
||||
|
||||
def perform_create(self, serializer):
|
||||
hub = self._get_hub()
|
||||
if not (hub.owner == self.request.user or self.request.user.is_superuser):
|
||||
raise PermissionDenied('Only the hub owner or superuser can add moderators.')
|
||||
serializer.save(hub=hub)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tags ViewSet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="List hub tags",
|
||||
description="Returns all tags for a given hub. Pass `hub` as a query param.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Retrieve a hub tag",
|
||||
),
|
||||
create=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Create a hub tag",
|
||||
description="Adds a tag to the hub. Owner, site admin, or moderator with `managing_posts`.",
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Update a hub tag",
|
||||
description="Updates a tag on the hub. Owner, site admin, or moderator with `managing_posts`.",
|
||||
),
|
||||
update=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Replace a hub tag",
|
||||
description="Replaces a tag on the hub. Owner, site admin, or moderator with `managing_posts`.",
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["hubs"],
|
||||
summary="Delete a hub tag",
|
||||
description="Removes a tag from the hub. Owner, site admin, or moderator with `managing_posts`.",
|
||||
),
|
||||
)
|
||||
class TagsViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = TagsSerializer
|
||||
permission_classes = [CanManageHubTags]
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name']
|
||||
ordering = ['name']
|
||||
|
||||
def _get_hub(self):
|
||||
hub_id = self.kwargs.get('hub_pk') or self.request.query_params.get('hub')
|
||||
return Hub.objects.get(pk=hub_id)
|
||||
|
||||
def get_queryset(self):
|
||||
return Tags.objects.filter(hub=self._get_hub())
|
||||
|
||||
def perform_create(self, serializer):
|
||||
hub = self._get_hub()
|
||||
user = self.request.user
|
||||
if not (user.is_superuser or hub.owner == user or hub.moderators.filter(user=user, managing_posts=True).exists()):
|
||||
raise PermissionDenied('Only the hub owner, superuser, or moderator with managing_posts can create tags.')
|
||||
serializer.save(hub=hub)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Social Feature Ideas
|
||||
|
||||
## Posts
|
||||
|
||||
## Users
|
||||
- ~~User blocking — affects message/post visibility across all social features~~ ✓ done (`UserBlock` in account, `has_blocked`/`is_blocked_by` helpers on CustomUser)
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -3,4 +3,6 @@ from django.apps import AppConfig
|
||||
|
||||
class PostsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'posts'
|
||||
name = 'social.posts'
|
||||
|
||||
label = "posts"
|
||||
|
||||
66
backend/social/posts/migrations/0001_initial.py
Normal file
66
backend/social/posts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-19 21:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('hubs', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Post',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('content', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL)),
|
||||
('hub', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='hubs.hub')),
|
||||
('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replies', to='posts.post')),
|
||||
('tags', models.ManyToManyField(blank=True, related_name='posts', to='hubs.tags')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PostContent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('mime_type', models.CharField(max_length=100)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to='post_contents/')),
|
||||
('alt_text', models.TextField(blank=True, null=True)),
|
||||
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contents', to='posts.post')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PostVote',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('vote', models.SmallIntegerField(choices=[(1, 'Upvote'), (-1, 'Downvote')])),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='posts.post')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('post', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,9 +1,22 @@
|
||||
from venv import logger
|
||||
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from vontor_cz.models import SoftDeleteModel
|
||||
import magic
|
||||
from logging import Logger
|
||||
|
||||
logger = Logger(__name__)
|
||||
|
||||
try:
|
||||
import magic
|
||||
mime = magic.Magic(mime=True)
|
||||
|
||||
except ImportError:
|
||||
logger.warning("python-magic library not found. PostContent MIME type detection will not work.")
|
||||
magic = None
|
||||
mime = None
|
||||
|
||||
|
||||
mime = magic.Magic(mime=True)
|
||||
|
||||
|
||||
class Post(SoftDeleteModel):
|
||||
@@ -20,12 +33,18 @@ class Post(SoftDeleteModel):
|
||||
)
|
||||
|
||||
hub = models.ForeignKey(
|
||||
'pages.Hub',
|
||||
'hubs.Hub',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
tags = models.ManyToManyField(
|
||||
'hubs.Tags',
|
||||
related_name='posts',
|
||||
blank=True,
|
||||
)
|
||||
|
||||
reply_to = models.ForeignKey(
|
||||
'self',
|
||||
null=True,
|
||||
|
||||
41
backend/social/posts/permissions.py
Normal file
41
backend/social/posts/permissions.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from rest_framework.permissions import IsAuthenticated, SAFE_METHODS
|
||||
|
||||
|
||||
class IsPostAuthorOnly(IsAuthenticated):
|
||||
"""
|
||||
View-level: must be authenticated (inherited).
|
||||
Object-level unsafe: post author only.
|
||||
Used for update / partial_update.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
|
||||
return obj.author == request.user
|
||||
|
||||
|
||||
class CanDeletePost(IsAuthenticated):
|
||||
"""
|
||||
View-level: must be authenticated (inherited).
|
||||
Object-level DELETE:
|
||||
- Post author
|
||||
- Superuser (anywhere)
|
||||
- Hub owner (if post belongs to a hub)
|
||||
- Hub moderator with managing_posts=True (if post belongs to a hub)
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
user = request.user
|
||||
if obj.author == user or user.is_superuser:
|
||||
return True
|
||||
|
||||
hub = obj.hub
|
||||
if hub:
|
||||
if hub.owner == user:
|
||||
return True
|
||||
|
||||
return hub.moderators.filter(user=user, managing_posts=True).exists()
|
||||
return False
|
||||
31
backend/social/posts/serializers.py
Normal file
31
backend/social/posts/serializers.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Post, PostContent, PostVote
|
||||
from social.hubs.serializers import TagsSerializer
|
||||
|
||||
|
||||
class PostContentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PostContent
|
||||
fields = ['id', 'mime_type', 'file', 'alt_text']
|
||||
read_only_fields = ['mime_type']
|
||||
|
||||
|
||||
class PostSerializer(serializers.ModelSerializer):
|
||||
contents = PostContentSerializer(many=True, read_only=True)
|
||||
tags = TagsSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Post
|
||||
fields = ['id', 'content', 'created_at', 'updated_at', 'author', 'hub', 'reply_to', 'tags', 'contents']
|
||||
read_only_fields = ['author', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class PostVoteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PostVote
|
||||
fields = ['id', 'post', 'user', 'vote', 'created_at']
|
||||
read_only_fields = ['user', 'created_at']
|
||||
|
||||
|
||||
class TagAttachSerializer(serializers.Serializer):
|
||||
tag_id = serializers.IntegerField(help_text="PK of the hub tag to attach or detach.")
|
||||
7
backend/social/posts/urls.py
Normal file
7
backend/social/posts/urls.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import PostViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('', PostViewSet, basename='post')
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -1,3 +1,157 @@
|
||||
from django.shortcuts import render
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from social.hubs.models import Tags
|
||||
from .models import Post, PostVote
|
||||
from .permissions import CanDeletePost, IsPostAuthorOnly
|
||||
from .serializers import PostSerializer, PostVoteSerializer, TagAttachSerializer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post ViewSet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
tags=["posts"],
|
||||
summary="List posts",
|
||||
description="Returns posts. Filter by `hub` query param to scope to a hub.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["posts"],
|
||||
summary="Retrieve a post",
|
||||
),
|
||||
create=extend_schema(
|
||||
tags=["posts"],
|
||||
summary="Create a post",
|
||||
description="Creates a post. The requesting user is set as the author automatically.",
|
||||
),
|
||||
update=extend_schema(
|
||||
tags=["posts"],
|
||||
summary="Replace a post",
|
||||
description="Full update. Author only.",
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
tags=["posts"],
|
||||
summary="Update a post",
|
||||
description="Partial update. Author only.",
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["posts"],
|
||||
summary="Delete a post",
|
||||
description="Soft-deletes the post. Author, superuser, hub owner, or hub moderator with `managing_posts`.",
|
||||
),
|
||||
)
|
||||
class PostViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = PostSerializer
|
||||
filterset_fields = ['hub', 'author', 'reply_to']
|
||||
search_fields = ['content']
|
||||
ordering_fields = ['created_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == 'destroy':
|
||||
return [CanDeletePost()]
|
||||
if self.action in ('update', 'partial_update'):
|
||||
return [IsPostAuthorOnly()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Post.objects.select_related('author', 'hub').prefetch_related('tags', 'contents')
|
||||
hub_id = self.request.query_params.get('hub')
|
||||
if hub_id:
|
||||
qs = qs.filter(hub_id=hub_id)
|
||||
return qs
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(author=self.request.user)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tag attachment actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["posts"],
|
||||
summary="Attach a tag to a post",
|
||||
description=(
|
||||
"Attaches an existing hub tag to the post. "
|
||||
"The tag must belong to the same hub as the post. "
|
||||
"Any authenticated hub member can attach tags."
|
||||
),
|
||||
request=TagAttachSerializer,
|
||||
responses={200: PostSerializer},
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='tags/attach')
|
||||
def attach_tag(self, request, pk=None):
|
||||
post = self.get_object()
|
||||
ser = TagAttachSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
tag = Tags.objects.get(pk=ser.validated_data['tag_id'], hub=post.hub)
|
||||
except Tags.DoesNotExist:
|
||||
raise ValidationError({'tag_id': 'Tag not found or does not belong to this post\'s hub.'})
|
||||
|
||||
post.tags.add(tag)
|
||||
return Response(PostSerializer(post, context={'request': request}).data)
|
||||
|
||||
@extend_schema(
|
||||
tags=["posts"],
|
||||
summary="Detach a tag from a post",
|
||||
description=(
|
||||
"Removes a tag from the post. "
|
||||
"Post author, hub owner, site admin, or moderator with `managing_posts` can detach."
|
||||
),
|
||||
request=TagAttachSerializer,
|
||||
responses={200: PostSerializer},
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='tags/detach')
|
||||
def detach_tag(self, request, pk=None):
|
||||
post = self.get_object()
|
||||
ser = TagAttachSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
|
||||
user = request.user
|
||||
hub = post.hub
|
||||
is_author = post.author == user
|
||||
is_hub_owner = hub and hub.owner == user
|
||||
is_moderator = hub and hub.moderators.filter(user=user, managing_posts=True).exists()
|
||||
|
||||
if not (is_author or user.is_superuser or is_hub_owner or is_moderator):
|
||||
raise PermissionDenied('Only the post author, hub owner, admin, or moderator with managing_posts can detach tags.')
|
||||
|
||||
try:
|
||||
tag = Tags.objects.get(pk=ser.validated_data['tag_id'], hub=post.hub)
|
||||
except Tags.DoesNotExist:
|
||||
raise ValidationError({'tag_id': 'Tag not found or does not belong to this post\'s hub.'})
|
||||
|
||||
post.tags.remove(tag)
|
||||
return Response(PostSerializer(post, context={'request': request}).data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Vote action
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["posts"],
|
||||
summary="Vote on a post",
|
||||
description="Cast or update a vote (`1` = upvote, `-1` = downvote) on a post.",
|
||||
request=PostVoteSerializer,
|
||||
responses={200: PostVoteSerializer},
|
||||
)
|
||||
@action(detail=True, methods=['post'])
|
||||
def vote(self, request, pk=None):
|
||||
post = self.get_object()
|
||||
ser = PostVoteSerializer(data={**request.data, 'post': post.pk, 'user': request.user.pk})
|
||||
ser.is_valid(raise_exception=True)
|
||||
vote_obj, _ = PostVote.objects.update_or_create(
|
||||
post=post,
|
||||
user=request.user,
|
||||
defaults={'vote': ser.validated_data['vote']},
|
||||
)
|
||||
return Response(PostVoteSerializer(vote_obj).data)
|
||||
|
||||
# Create your views here.
|
||||
|
||||
Reference in New Issue
Block a user