gukgjzkgjhgjh
This commit is contained in:
@@ -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