added frontend for social + feed partiali working
This commit is contained in:
16
backend/account/migrations/0002_customuser_avatar.py
Normal file
16
backend/account/migrations/0002_customuser_avatar.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('account', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='avatar',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='avatars/'),
|
||||
),
|
||||
]
|
||||
@@ -78,6 +78,8 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
||||
street_number = models.PositiveIntegerField(null=True, blank=True)
|
||||
country = models.CharField(null=True, blank=True, max_length=100)
|
||||
|
||||
avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
|
||||
|
||||
# firemní fakturační údaje
|
||||
company_name = models.CharField(max_length=255, blank=True)
|
||||
ico = models.CharField(max_length=20, blank=True)
|
||||
@@ -136,8 +138,6 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
||||
group, _ = Group.objects.get_or_create(name=self.role)
|
||||
# Use add/set now that PK exists
|
||||
self.groups.set([group])
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def generate_email_verification_token(self, length: int = 48, save: bool = True) -> str:
|
||||
token = get_random_string(length=length)
|
||||
|
||||
@@ -17,6 +17,14 @@ from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
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']
|
||||
|
||||
|
||||
class CustomUserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
@@ -35,6 +43,7 @@ class CustomUserSerializer(serializers.ModelSerializer):
|
||||
"postal_code",
|
||||
"gdpr",
|
||||
"is_active",
|
||||
"avatar",
|
||||
]
|
||||
read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type"
|
||||
|
||||
@@ -89,17 +98,18 @@ class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'first_name', 'last_name', 'email', 'phone_number', 'password',
|
||||
'username', 'first_name', 'last_name', 'email', 'phone_number', 'password',
|
||||
'city', 'street', 'postal_code', 'gdpr'
|
||||
]
|
||||
extra_kwargs = {
|
||||
'first_name': {'required': True, 'help_text': 'Křestní jméno uživatele'},
|
||||
'last_name': {'required': True, 'help_text': 'Příjmení uživatele'},
|
||||
'username': {'required': False, 'allow_blank': True, 'help_text': 'Užívatelské jméno'},
|
||||
'first_name': {'required': False, 'allow_blank': True, 'help_text': 'Křestní jméno uživatele'},
|
||||
'last_name': {'required': False, 'allow_blank': True, 'help_text': 'Příjmení uživatele'},
|
||||
'email': {'required': True, 'help_text': 'Emailová adresa uživatele'},
|
||||
'phone_number': {'required': True, 'help_text': 'Telefonní číslo uživatele'},
|
||||
'city': {'required': True, 'help_text': 'Město uživatele'},
|
||||
'street': {'required': True, 'help_text': 'Ulice uživatele'},
|
||||
'postal_code': {'required': True, 'help_text': 'PSČ uživatele'},
|
||||
'phone_number': {'required': False, 'allow_null': True, 'allow_blank': True, 'help_text': 'Telefonní číslo uživatele'},
|
||||
'city': {'required': False, 'allow_blank': True, 'allow_null': True, 'help_text': 'Město uživatele'},
|
||||
'street': {'required': False, 'allow_blank': True, 'allow_null': True, 'help_text': 'Ulice uživatele'},
|
||||
'postal_code': {'required': False, 'allow_blank': True, 'allow_null': True, 'help_text': 'PSČ uživatele'},
|
||||
'gdpr': {'required': True, 'help_text': 'Souhlas se zpracováním osobních údajů'},
|
||||
}
|
||||
|
||||
@@ -117,9 +127,9 @@ class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||
def validate(self, data):
|
||||
email = data.get("email")
|
||||
phone = data.get("phone_number")
|
||||
dgpr = data.get("GDPR")
|
||||
if not dgpr:
|
||||
raise serializers.ValidationError({"GDPR": "You must agree to the GDPR to register."})
|
||||
gdpr = data.get("gdpr")
|
||||
if not gdpr:
|
||||
raise serializers.ValidationError({"gdpr": "You must agree to the GDPR to register."})
|
||||
|
||||
if User.objects.filter(email=email).exists():
|
||||
raise serializers.ValidationError({"email": "Account with this email already exists."})
|
||||
@@ -131,10 +141,8 @@ class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
password = validated_data.pop("password")
|
||||
username = validated_data.get("username", "")
|
||||
user = User.objects.create(
|
||||
username=username,
|
||||
is_active=False, #uživatel je defaultně deaktivovaný
|
||||
is_active=True, #uživatel je defaultně aktivní
|
||||
**validated_data
|
||||
)
|
||||
user.set_password(password)
|
||||
|
||||
@@ -250,21 +250,20 @@ class UserView(viewsets.ModelViewSet):
|
||||
# Fallback - deny access (prevents AttributeError for AnonymousUser)
|
||||
return [OnlyRolesAllowed("admin")()]
|
||||
|
||||
# Users can only view their own profile, admins can view any profile
|
||||
# Any authenticated user can retrieve a profile (serializer limits fields for non-owner/non-admin)
|
||||
elif self.action == 'retrieve':
|
||||
user = getattr(self, 'request', None) and getattr(self.request, 'user', None)
|
||||
# Admins can view any user profile
|
||||
if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin':
|
||||
return [IsAuthenticated()]
|
||||
|
||||
# Users can view their own profile
|
||||
if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']:
|
||||
return [IsAuthenticated()]
|
||||
|
||||
# Deny access to other users' profiles
|
||||
return [OnlyRolesAllowed("admin")()]
|
||||
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:
|
||||
return PublicUserSerializer
|
||||
return CustomUserSerializer
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -179,147 +179,3 @@ def _create_message(chat_id, sender, content, reply_to_id=None):
|
||||
def _toggle_reaction(message_id, user, emoji):
|
||||
message = Message.objects.get(pk=message_id)
|
||||
return message.toggle_reaction(user, emoji)
|
||||
|
||||
|
||||
|
||||
class ChatConsumer(AsyncWebsocketConsumer):
|
||||
# -- CONNECT --
|
||||
async def connect(self):
|
||||
self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]
|
||||
self.chat_name = f"chat_{self.chat_id}"
|
||||
|
||||
user = self.scope["user"]
|
||||
|
||||
if not user.is_authenticated:
|
||||
await self.close(code=4401) # unauthorized
|
||||
return
|
||||
|
||||
#join chat group
|
||||
async_to_sync(self.channel_layer.group_add)(
|
||||
self.chat_name,
|
||||
)
|
||||
|
||||
|
||||
|
||||
await self.accept()
|
||||
|
||||
# -- DISCONNECT --
|
||||
async def disconnect(self, close_code):
|
||||
async_to_sync(self.channel_layer.group_discard)(
|
||||
self.chat_name
|
||||
)
|
||||
|
||||
self.disconnect()
|
||||
pass
|
||||
|
||||
# -- RECIVE --
|
||||
async def receive(self, data):
|
||||
if data["type"] == "new_chat_message":
|
||||
|
||||
message = data["message"]
|
||||
|
||||
# Send message to room group
|
||||
async_to_sync(self.channel_layer.group_send)(
|
||||
self.chat_name, {"type": "chat.message", "message": message}
|
||||
)
|
||||
|
||||
elif data["type"] == "new_reply_chat_message":
|
||||
message = data["message"]
|
||||
reply_to_id = data["reply_to_id"]
|
||||
|
||||
# Send message to room group
|
||||
async_to_sync(self.channel_layer.group_send)(
|
||||
self.chat_name, {"type": "reply.chat.message", "message": message, "reply_to_id": reply_to_id}
|
||||
)
|
||||
|
||||
elif data["type"] == "edit_chat_message":
|
||||
message = data["message"]
|
||||
|
||||
# Send message to room group
|
||||
async_to_sync(self.channel_layer.group_send)(
|
||||
self.chat_name, {"type": "edit.message", "message": message}
|
||||
)
|
||||
|
||||
elif data["type"] == "delete_chat_message":
|
||||
message_id = data["message_id"]
|
||||
|
||||
# Send message to room group
|
||||
async_to_sync(self.channel_layer.group_send)(
|
||||
self.chat_name, {"type": "delete.message", "message_id": message_id}
|
||||
)
|
||||
|
||||
elif data["type"] == "typing":
|
||||
is_typing = data["is_typing"]
|
||||
|
||||
# Send typing status to room group
|
||||
async_to_sync(self.channel_layer.group_send)(
|
||||
self.chat_name, {"type": "typing.status", "user": self.scope["user"].username, "is_typing": is_typing}
|
||||
)
|
||||
|
||||
elif data["type"] == "stop_typing":
|
||||
# Send stop typing status to room group
|
||||
async_to_sync(self.channel_layer.group_send)(
|
||||
self.chat_name, {"type": "stop.typing", "user": self.scope["user"].username}
|
||||
)
|
||||
|
||||
elif data["type"] == "reaction":
|
||||
message_id = data["message_id"]
|
||||
emoji = data["emoji"]
|
||||
|
||||
# Send reaction to room group
|
||||
async_to_sync(self.channel_layer.group_send)(
|
||||
self.chat_name, {"type": "message.reaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
|
||||
)
|
||||
|
||||
elif data["type"] == "unreaction":
|
||||
message_id = data["message_id"]
|
||||
emoji = data["emoji"]
|
||||
|
||||
# Send unreaction to room group
|
||||
async_to_sync(self.channel_layer.group_send)(
|
||||
self.chat_name, {"type": "message.unreaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
|
||||
)
|
||||
|
||||
else:
|
||||
self.close(reason="Unsupported message type")
|
||||
|
||||
|
||||
# -- CUSTOM METHODS --
|
||||
|
||||
def send_message_to_chat_group(self, event):
|
||||
message = event["message"]
|
||||
create_new_message()
|
||||
self.send(text_data=json.dumps({"message": message}))
|
||||
|
||||
def edit_message_in_chat_group(self, event):
|
||||
message = event["message"]
|
||||
self.send(text_data=json.dumps({"message": message}))
|
||||
|
||||
|
||||
|
||||
# -- MESSAGES --
|
||||
@database_sync_to_async
|
||||
def create_new_message():
|
||||
return None
|
||||
|
||||
@database_sync_to_async
|
||||
def create_new_reply_message():
|
||||
return None
|
||||
|
||||
@database_sync_to_async
|
||||
def edit_message():
|
||||
return None
|
||||
|
||||
@database_sync_to_async
|
||||
def delete_message():
|
||||
return None
|
||||
|
||||
|
||||
# -- REACTIONS --
|
||||
@database_sync_to_async
|
||||
def react_to_message():
|
||||
return None
|
||||
|
||||
@database_sync_to_async
|
||||
def unreact_to_message():
|
||||
return None
|
||||
@@ -8,6 +8,7 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from vontor_cz.pagination import CreatedCursorPagination
|
||||
from .models import Chat, Message, MessageFile
|
||||
from .permissions import CanDeleteMessage, CanManageChat, IsChatMember, IsMessageSenderOnly
|
||||
from .serializers import ChatMemberSerializer, ChatSerializer, MessageSendSerializer, MessageSerializer
|
||||
@@ -155,10 +156,11 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
class MessageViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = MessageSerializer
|
||||
pagination_class = CreatedCursorPagination
|
||||
filterset_fields = ['chat', 'sender', 'reply_to']
|
||||
search_fields = ['content']
|
||||
ordering_fields = ['created_at']
|
||||
ordering = ['created_at']
|
||||
ordering = ['-created_at']
|
||||
# Standard create is disabled — use POST /messages/send which handles files + WS broadcast
|
||||
http_method_names = ['get', 'patch', 'put', 'delete', 'post', 'head', 'options']
|
||||
|
||||
|
||||
@@ -67,7 +67,18 @@ class PostContent(SoftDeleteModel):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.file and not self.file._committed:
|
||||
self.mime_type = mime.from_buffer(self.file.read(1024))
|
||||
if mime is not None:
|
||||
data = self.file.read(1024)
|
||||
self.file.seek(0)
|
||||
self.mime_type = mime.from_buffer(data)
|
||||
else:
|
||||
import mimetypes
|
||||
content_type = getattr(self.file, 'content_type', None)
|
||||
self.mime_type = (
|
||||
content_type
|
||||
or mimetypes.guess_type(self.file.name)[0]
|
||||
or 'application/octet-stream'
|
||||
)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
from .models import Post, PostContent, PostVote
|
||||
from social.hubs.serializers import TagsSerializer
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class AuthorMinimalSerializer(serializers.ModelSerializer):
|
||||
avatar = serializers.ImageField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'first_name', 'last_name', 'avatar']
|
||||
|
||||
|
||||
class PostContentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@@ -13,12 +24,32 @@ class PostContentSerializer(serializers.ModelSerializer):
|
||||
class PostSerializer(serializers.ModelSerializer):
|
||||
contents = PostContentSerializer(many=True, read_only=True)
|
||||
tags = TagsSerializer(many=True, read_only=True)
|
||||
author_detail = AuthorMinimalSerializer(source='author', read_only=True)
|
||||
vote_score = serializers.SerializerMethodField()
|
||||
user_vote = serializers.SerializerMethodField()
|
||||
reply_count = serializers.IntegerField(read_only=True, default=0)
|
||||
|
||||
class Meta:
|
||||
model = Post
|
||||
fields = ['id', 'content', 'created_at', 'updated_at', 'author', 'hub', 'reply_to', 'tags', 'contents']
|
||||
fields = [
|
||||
'id', 'content', 'created_at', 'updated_at',
|
||||
'author', 'author_detail',
|
||||
'hub', 'reply_to',
|
||||
'tags', 'contents',
|
||||
'vote_score', 'user_vote', 'reply_count',
|
||||
]
|
||||
read_only_fields = ['author', 'created_at', 'updated_at']
|
||||
|
||||
def get_vote_score(self, obj):
|
||||
return sum(v.vote for v in obj.votes.all())
|
||||
|
||||
def get_user_vote(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request or not request.user.is_authenticated:
|
||||
return 0
|
||||
vote = obj.votes.filter(user=request.user).first()
|
||||
return vote.vote if vote else 0
|
||||
|
||||
|
||||
class PostVoteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from django.db.models import Count, Q
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from social.hubs.models import Tags
|
||||
from .models import Post, PostVote
|
||||
from vontor_cz.pagination import CreatedCursorPagination
|
||||
from .models import Post, PostContent, PostVote
|
||||
from .permissions import CanDeletePost, IsPostAuthorOnly
|
||||
from .serializers import PostSerializer, PostVoteSerializer, TagAttachSerializer
|
||||
from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -61,7 +64,12 @@ class PostViewSet(viewsets.ModelViewSet):
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Post.objects.select_related('author', 'hub').prefetch_related('tags', 'contents')
|
||||
qs = (
|
||||
Post.objects
|
||||
.select_related('author', 'hub')
|
||||
.prefetch_related('tags', 'contents', 'votes')
|
||||
.annotate(reply_count=Count('replies', distinct=True))
|
||||
)
|
||||
hub_id = self.request.query_params.get('hub')
|
||||
if hub_id:
|
||||
qs = qs.filter(hub_id=hub_id)
|
||||
@@ -70,6 +78,33 @@ class PostViewSet(viewsets.ModelViewSet):
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(author=self.request.user)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Media upload action
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["posts"],
|
||||
summary="Upload media to a post",
|
||||
description="Attach an image or video file to a post. Only the post author can upload.",
|
||||
request={'multipart/form-data': {
|
||||
'type': 'object',
|
||||
'properties': {'file': {'type': 'string', 'format': 'binary'}},
|
||||
'required': ['file'],
|
||||
}},
|
||||
responses={201: PostContentSerializer},
|
||||
)
|
||||
@action(detail=True, methods=['post'], url_path='media', parser_classes=[MultiPartParser])
|
||||
def upload_media(self, request, pk=None):
|
||||
post = self.get_object()
|
||||
if post.author != request.user:
|
||||
raise PermissionDenied('Only the post author can upload media.')
|
||||
file = request.FILES.get('file')
|
||||
if not file:
|
||||
raise ValidationError({'file': 'No file provided.'})
|
||||
content = PostContent(post=post, file=file)
|
||||
content.save()
|
||||
return Response(PostContentSerializer(content).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tag attachment actions
|
||||
# ------------------------------------------------------------------
|
||||
@@ -132,6 +167,58 @@ class PostViewSet(viewsets.ModelViewSet):
|
||||
post.tags.remove(tag)
|
||||
return Response(PostSerializer(post, context={'request': request}).data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Feed action (cursor-paginated aggregated feed for the user)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["posts"],
|
||||
summary="Get the user's post feed",
|
||||
description=(
|
||||
"Returns a cursor-paginated stream of top-level posts (excluding replies) "
|
||||
"aggregated from the user's joined hubs, public hubs, and hub-less posts. "
|
||||
"Pass `feed_strategy` to switch between ranking algorithms (currently only "
|
||||
"`recent` is implemented; reserved for future custom algorithms)."
|
||||
),
|
||||
parameters=[
|
||||
OpenApiParameter(name='feed_strategy', required=False, type=str,
|
||||
description="Algorithm key, default `recent`."),
|
||||
OpenApiParameter(name='cursor', required=False, type=str,
|
||||
description="Opaque pagination cursor."),
|
||||
],
|
||||
responses={200: PostSerializer(many=True)},
|
||||
)
|
||||
@action(detail=False, methods=['get'], url_path='feed')
|
||||
def feed(self, request):
|
||||
user = request.user
|
||||
strategy = request.query_params.get('feed_strategy', 'recent')
|
||||
|
||||
base_qs = (
|
||||
Post.objects
|
||||
.select_related('author', 'hub')
|
||||
.prefetch_related('tags', 'contents', 'votes')
|
||||
.annotate(reply_count=Count('replies', distinct=True))
|
||||
.filter(reply_to__isnull=True)
|
||||
)
|
||||
|
||||
joined_hub_ids = list(user.hubs.values_list('id', flat=True)) if user.is_authenticated else []
|
||||
visibility_filter = (
|
||||
Q(hub__isnull=True)
|
||||
| Q(hub__is_public=True)
|
||||
| Q(hub_id__in=joined_hub_ids)
|
||||
)
|
||||
qs = base_qs.filter(visibility_filter).distinct()
|
||||
|
||||
if strategy == 'recent':
|
||||
qs = qs.order_by('-created_at')
|
||||
else:
|
||||
qs = qs.order_by('-created_at')
|
||||
|
||||
paginator = CreatedCursorPagination()
|
||||
page = paginator.paginate_queryset(qs, request, view=self)
|
||||
ser = PostSerializer(page, many=True, context={'request': request})
|
||||
return paginator.get_paginated_response(ser.data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Vote action
|
||||
# ------------------------------------------------------------------
|
||||
@@ -154,4 +241,3 @@ class PostViewSet(viewsets.ModelViewSet):
|
||||
defaults={'vote': ser.validated_data['vote']},
|
||||
)
|
||||
return Response(PostVoteSerializer(vote_obj).data)
|
||||
|
||||
|
||||
31
backend/vontor_cz/pagination.py
Normal file
31
backend/vontor_cz/pagination.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Shared pagination classes.
|
||||
|
||||
`CreatedCursorPagination` is the canonical choice for infinite-scroll feeds
|
||||
(posts feed, chat message history). Cursor pagination keeps a stable view of
|
||||
results even when new items are created at the head, which page/offset
|
||||
pagination does not.
|
||||
"""
|
||||
|
||||
from rest_framework.pagination import CursorPagination
|
||||
|
||||
|
||||
class CreatedCursorPagination(CursorPagination):
|
||||
"""Cursor pagination ordered by `-created_at` (newest first)."""
|
||||
page_size = 20
|
||||
ordering = '-created_at'
|
||||
cursor_query_param = 'cursor'
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class CreatedAscCursorPagination(CursorPagination):
|
||||
"""Cursor pagination ordered by `created_at` (oldest first).
|
||||
|
||||
Used for chat history scroll-back where messages are displayed oldest -> newest
|
||||
and pagination walks backwards in time from the most recent.
|
||||
"""
|
||||
page_size = 30
|
||||
ordering = '-created_at' # backend orders newest-first; client reverses for display
|
||||
cursor_query_param = 'cursor'
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
Reference in New Issue
Block a user