posts are done
This commit is contained in:
@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
|
||||
class UserFilter(django_filters.FilterSet):
|
||||
username = django_filters.CharFilter(field_name="username", lookup_expr="exact")
|
||||
role = django_filters.CharFilter(field_name="role", lookup_expr="exact")
|
||||
email = django_filters.CharFilter(field_name="email", lookup_expr="icontains")
|
||||
phone_number = django_filters.CharFilter(field_name="phone_number", lookup_expr="icontains")
|
||||
@@ -19,6 +20,6 @@ class UserFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"role", "email", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified",
|
||||
"username", "role", "email", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified",
|
||||
"create_time_after", "create_time_before"
|
||||
]
|
||||
|
||||
18
backend/account/migrations/0003_customuser_banner.py
Normal file
18
backend/account/migrations/0003_customuser_banner.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-18 20:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('account', '0002_customuser_avatar'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='banner',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='banners/'),
|
||||
),
|
||||
]
|
||||
@@ -44,9 +44,9 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
||||
class Role(models.TextChoices):
|
||||
ADMIN = "admin", "cz#Administrátor"
|
||||
MANAGER = "mod", "cz#Moderator"
|
||||
CUSTOMER = "regular", "cz#Regular"
|
||||
REGULAR = "regular", "cz#Regular"
|
||||
|
||||
role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER)
|
||||
role = models.CharField(max_length=20, choices=Role.choices, default=Role.REGULAR)
|
||||
|
||||
phone_number = models.CharField(
|
||||
null=True,
|
||||
@@ -79,6 +79,7 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
||||
country = models.CharField(null=True, blank=True, max_length=100)
|
||||
|
||||
avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
|
||||
banner = models.ImageField(upload_to='banners/', null=True, blank=True)
|
||||
|
||||
# firemní fakturační údaje
|
||||
company_name = models.CharField(max_length=255, blank=True)
|
||||
|
||||
@@ -21,8 +21,8 @@ class PublicUserSerializer(serializers.ModelSerializer):
|
||||
"""Minimal read-only profile returned to non-owner authenticated users."""
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'first_name', 'last_name', 'avatar', 'city', 'role', 'create_time']
|
||||
read_only_fields = ['id', 'username', 'first_name', 'last_name', 'avatar', 'city', 'role', 'create_time']
|
||||
fields = ['id', 'username', 'first_name', 'last_name', 'avatar', 'banner', 'city', 'role', 'create_time']
|
||||
read_only_fields = ['id', 'username', 'first_name', 'last_name', 'avatar', 'banner', 'city', 'role', 'create_time']
|
||||
|
||||
|
||||
class CustomUserSerializer(serializers.ModelSerializer):
|
||||
@@ -44,6 +44,7 @@ class CustomUserSerializer(serializers.ModelSerializer):
|
||||
"gdpr",
|
||||
"is_active",
|
||||
"avatar",
|
||||
"banner",
|
||||
]
|
||||
read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type"
|
||||
|
||||
@@ -201,6 +202,23 @@ class PasswordResetConfirmSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
def validate_password(self, value):
|
||||
import re
|
||||
if len(value) < 8:
|
||||
raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.")
|
||||
if not re.search(r"[A-Z]", value):
|
||||
raise serializers.ValidationError("Musí obsahovat velké písmeno.")
|
||||
if not re.search(r"[a-z]", value):
|
||||
raise serializers.ValidationError("Musí obsahovat malé písmeno.")
|
||||
if not re.search(r"\d", value):
|
||||
raise serializers.ValidationError("Musí obsahovat číslici.")
|
||||
return value
|
||||
|
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer):
|
||||
current_password = serializers.CharField(write_only=True)
|
||||
new_password = serializers.CharField(write_only=True)
|
||||
|
||||
def validate_new_password(self, value):
|
||||
import re
|
||||
if len(value) < 8:
|
||||
raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.")
|
||||
|
||||
@@ -20,6 +20,7 @@ urlpatterns = [
|
||||
# Password reset endpoints
|
||||
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
||||
path('password-reset-confirm/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password-reset-confirm'),
|
||||
path('password-change/', views.ChangePasswordView.as_view(), name='password-change'),
|
||||
|
||||
# User CRUD (list, retrieve, update, delete)
|
||||
path('', include(router.urls)), #/users/
|
||||
|
||||
@@ -232,36 +232,29 @@ class UserView(viewsets.ModelViewSet):
|
||||
},
|
||||
)
|
||||
def get_permissions(self):
|
||||
# Only admin can list or create users
|
||||
if self.action in ['list', 'create']:
|
||||
if self.action == 'create':
|
||||
return [OnlyRolesAllowed("admin")()]
|
||||
|
||||
# Only admin or the user themselves can update or delete
|
||||
elif self.action in ['update', 'partial_update', 'destroy']:
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return [IsAuthenticated()]
|
||||
|
||||
if self.action in ['update', 'partial_update', 'destroy']:
|
||||
user = getattr(self, 'request', None) and getattr(self.request, 'user', None)
|
||||
# Admins can modify any user
|
||||
if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin':
|
||||
return [OnlyRolesAllowed("admin")()]
|
||||
|
||||
# Users can modify their own record
|
||||
if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']:
|
||||
lookup = self.kwargs.get('pk', '')
|
||||
if user and getattr(user, 'is_authenticated', False) and lookup and (
|
||||
str(getattr(user, 'id', '')) == lookup
|
||||
):
|
||||
return [IsAuthenticated()]
|
||||
|
||||
# Fallback - deny access (prevents AttributeError for AnonymousUser)
|
||||
return [OnlyRolesAllowed("admin")()]
|
||||
|
||||
# Any authenticated user can retrieve a profile (serializer limits fields for non-owner/non-admin)
|
||||
elif self.action == 'retrieve':
|
||||
return [IsAuthenticated()]
|
||||
|
||||
return super().get_permissions()
|
||||
|
||||
def get_serializer_class(self):
|
||||
user = getattr(self.request, 'user', None)
|
||||
pk = self.kwargs.get('pk')
|
||||
is_self = pk and user and str(getattr(user, 'id', '')) == str(pk)
|
||||
is_admin = user and (getattr(user, 'role', None) == 'admin' or getattr(user, 'is_superuser', False))
|
||||
if self.action == 'retrieve' and not is_self and not is_admin:
|
||||
if self.action in ['retrieve', 'list'] and not is_admin:
|
||||
return PublicUserSerializer
|
||||
return CustomUserSerializer
|
||||
|
||||
@@ -285,6 +278,29 @@ class CurrentUserView(APIView):
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["account"],
|
||||
summary="Change password for the authenticated user",
|
||||
request=ChangePasswordSerializer,
|
||||
responses={
|
||||
200: OpenApiResponse(description="Password changed successfully."),
|
||||
400: OpenApiResponse(description="Invalid current password or validation error."),
|
||||
},
|
||||
)
|
||||
class ChangePasswordView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
serializer = ChangePasswordSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = request.user
|
||||
if not user.check_password(serializer.validated_data['current_password']):
|
||||
return Response({"current_password": "Nesprávné heslo."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.set_password(serializer.validated_data['new_password'])
|
||||
user.save()
|
||||
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
||||
|
||||
|
||||
#------------------------------------------------REGISTRACE--------------------------------------------------------------
|
||||
|
||||
#1. registration API
|
||||
|
||||
28
backend/social/posts/migrations/0002_postsave.py
Normal file
28
backend/social/posts/migrations/0002_postsave.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-18 19:20
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('posts', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PostSave',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saves', to='posts.post')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_posts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('post', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -82,6 +82,18 @@ class PostContent(SoftDeleteModel):
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PostSave(models.Model):
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='saves')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='saved_posts')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('post', 'user')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} saved Post {self.post_id}"
|
||||
|
||||
|
||||
class PostVote(SoftDeleteModel):
|
||||
class VoteChoice(models.IntegerChoices):
|
||||
UP = 1, 'Upvote'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
from .models import Post, PostContent, PostVote
|
||||
from .models import Post, PostContent, PostVote, PostSave
|
||||
from social.hubs.serializers import TagsSerializer
|
||||
|
||||
User = get_user_model()
|
||||
@@ -28,6 +28,8 @@ class PostSerializer(serializers.ModelSerializer):
|
||||
vote_score = serializers.SerializerMethodField()
|
||||
user_vote = serializers.SerializerMethodField()
|
||||
reply_count = serializers.IntegerField(read_only=True, default=0)
|
||||
is_saved = serializers.SerializerMethodField()
|
||||
save_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Post
|
||||
@@ -36,7 +38,7 @@ class PostSerializer(serializers.ModelSerializer):
|
||||
'author', 'author_detail',
|
||||
'hub', 'reply_to',
|
||||
'tags', 'contents',
|
||||
'vote_score', 'user_vote', 'reply_count',
|
||||
'vote_score', 'user_vote', 'reply_count', 'is_saved', 'save_count',
|
||||
]
|
||||
read_only_fields = ['author', 'created_at', 'updated_at']
|
||||
|
||||
@@ -50,6 +52,15 @@ class PostSerializer(serializers.ModelSerializer):
|
||||
vote = obj.votes.filter(user=request.user).first()
|
||||
return vote.vote if vote else 0
|
||||
|
||||
def get_is_saved(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request or not request.user.is_authenticated:
|
||||
return False
|
||||
return obj.saves.filter(user=request.user).exists()
|
||||
|
||||
def get_save_count(self, obj):
|
||||
return obj.saves.count()
|
||||
|
||||
|
||||
class PostVoteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -9,7 +9,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiPara
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from social.hubs.models import Tags
|
||||
from vontor_cz.pagination import CreatedCursorPagination
|
||||
from .models import Post, PostContent, PostVote
|
||||
from .models import Post, PostContent, PostVote, PostSave
|
||||
from .permissions import CanDeletePost, IsPostAuthorOnly
|
||||
from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer
|
||||
|
||||
@@ -67,7 +67,7 @@ class PostViewSet(viewsets.ModelViewSet):
|
||||
qs = (
|
||||
Post.objects
|
||||
.select_related('author', 'hub')
|
||||
.prefetch_related('tags', 'contents', 'votes')
|
||||
.prefetch_related('tags', 'contents', 'votes', 'saves')
|
||||
.annotate(reply_count=Count('replies', distinct=True))
|
||||
)
|
||||
hub_id = self.request.query_params.get('hub')
|
||||
@@ -196,7 +196,7 @@ class PostViewSet(viewsets.ModelViewSet):
|
||||
base_qs = (
|
||||
Post.objects
|
||||
.select_related('author', 'hub')
|
||||
.prefetch_related('tags', 'contents', 'votes')
|
||||
.prefetch_related('tags', 'contents', 'votes', 'saves')
|
||||
.annotate(reply_count=Count('replies', distinct=True))
|
||||
.filter(reply_to__isnull=True)
|
||||
)
|
||||
@@ -241,3 +241,45 @@ class PostViewSet(viewsets.ModelViewSet):
|
||||
defaults={'vote': ser.validated_data['vote']},
|
||||
)
|
||||
return Response(PostVoteSerializer(vote_obj).data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Save / unsave action
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["posts"],
|
||||
summary="Toggle save on a post",
|
||||
description="Saves the post for the current user, or unsaves it if already saved. Returns `{saved: true/false}`.",
|
||||
responses={200: {'type': 'object', 'properties': {'saved': {'type': 'boolean'}}}},
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='save')
|
||||
def save_post(self, request, pk=None):
|
||||
post = self.get_object()
|
||||
obj, created = PostSave.objects.get_or_create(post=post, user=request.user)
|
||||
if not created:
|
||||
obj.delete()
|
||||
return Response({'saved': created})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Saved feed
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["posts"],
|
||||
summary="List posts saved by the current user",
|
||||
responses={200: PostSerializer(many=True)},
|
||||
)
|
||||
@action(detail=False, methods=['get'], url_path='saved')
|
||||
def saved(self, request):
|
||||
qs = (
|
||||
Post.objects
|
||||
.select_related('author', 'hub')
|
||||
.prefetch_related('tags', 'contents', 'votes', 'saves')
|
||||
.annotate(reply_count=Count('replies', distinct=True))
|
||||
.filter(saves__user=request.user)
|
||||
.order_by('-saves__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)
|
||||
|
||||
Reference in New Issue
Block a user