From 19c9831a595c4fe19c0e4ed5d51e9a52db5ff22e Mon Sep 17 00:00:00 2001 From: Shivam Rastogi Date: Wed, 6 Mar 2024 10:42:53 -0800 Subject: [PATCH] add user permissions for chat and chat messages --- .../babblebox/api/clients/pulsar_client.py | 21 ----- .../api/clients/pulsar_client_avro.py | 14 +-- ..._participant_chat_participants_and_more.py | 44 ++++++++++ ...e_chat_participants_chat_owner_and_more.py | 77 +++++++++++++++++ .../api/migrations/0004_chat_participants.py | 19 ++++ .../api/migrations/0005_chat_is_public.py | 17 ++++ babblebox/babblebox/api/models.py | 49 +++++++++-- babblebox/babblebox/api/permissions.py | 26 ++++++ babblebox/babblebox/api/serializers.py | 12 ++- babblebox/babblebox/api/urls.py | 5 +- babblebox/babblebox/api/views.py | 86 ++++++++++++++++++- babblebox/config/settings/base.py | 1 + babblebox/config/settings/local.py | 1 + 13 files changed, 332 insertions(+), 40 deletions(-) delete mode 100644 babblebox/babblebox/api/clients/pulsar_client.py create mode 100644 babblebox/babblebox/api/migrations/0002_participant_chat_participants_and_more.py create mode 100644 babblebox/babblebox/api/migrations/0003_chatparticipant_remove_chat_participants_chat_owner_and_more.py create mode 100644 babblebox/babblebox/api/migrations/0004_chat_participants.py create mode 100644 babblebox/babblebox/api/migrations/0005_chat_is_public.py create mode 100644 babblebox/babblebox/api/permissions.py diff --git a/babblebox/babblebox/api/clients/pulsar_client.py b/babblebox/babblebox/api/clients/pulsar_client.py deleted file mode 100644 index a28e12d..0000000 --- a/babblebox/babblebox/api/clients/pulsar_client.py +++ /dev/null @@ -1,21 +0,0 @@ -import random -from time import sleep - -import pulsar - -client = pulsar.Client("pulsar://10.2.115.98:6650") -print("Pulsar Python client version:", pulsar.__version__) - -# Create a producer on the topic with tenant and namespace -topic = 'persistent://babblebox/audio_processing/test' -producer = client.create_producer(topic) - -# Now you can send messages -for i in range(100, 500): - # random_num = random.randint(1, 3) - # sleep(random_num) - producer.send(('hello-pulsar-%d' % i).encode('utf-8')) - print("Message sent: ", i) -# Close the producer and client after done -producer.close() -client.close() \ No newline at end of file diff --git a/babblebox/babblebox/api/clients/pulsar_client_avro.py b/babblebox/babblebox/api/clients/pulsar_client_avro.py index 4a01f5b..fc3f2a3 100644 --- a/babblebox/babblebox/api/clients/pulsar_client_avro.py +++ b/babblebox/babblebox/api/clients/pulsar_client_avro.py @@ -8,12 +8,14 @@ topic = settings.NEW_MESSAGE_TOPIC client = None producer = None -try: - client = pulsar.Client(settings.PULSAR_URL) - print("No client created") - producer = client.create_producer(topic, schema=BytesSchema()) -except Exception as e: - print("Exception raised" + str(e)) +if settings.DISABLE_PULSAR_CLIENT is True: + print("Pulsar is enabled") + try: + client = pulsar.Client(settings.PULSAR_URL) + print("No client created") + producer = client.create_producer(topic, schema=BytesSchema()) + except Exception as e: + print("Exception raised" + str(e)) class PulsarClient: @classmethod diff --git a/babblebox/babblebox/api/migrations/0002_participant_chat_participants_and_more.py b/babblebox/babblebox/api/migrations/0002_participant_chat_participants_and_more.py new file mode 100644 index 0000000..d858cf1 --- /dev/null +++ b/babblebox/babblebox/api/migrations/0002_participant_chat_participants_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.9 on 2024-03-06 01:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Participant", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("is_owner", models.BooleanField(default=False)), + ("has_read_access", models.BooleanField(default=True)), + ("has_write_access", models.BooleanField(default=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ("chat", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="api.chat")), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name="chat", + name="participants", + field=models.ManyToManyField(through="api.Participant", to=settings.AUTH_USER_MODEL), + ), + migrations.AddIndex( + model_name="participant", + index=models.Index(fields=["chat"], name="chat_participant_idx"), + ), + migrations.AddIndex( + model_name="participant", + index=models.Index(fields=["user"], name="user_participant_idx"), + ), + migrations.AddIndex( + model_name="participant", + index=models.Index(fields=["chat", "user"], name="chat_user_participant_idx"), + ), + ] diff --git a/babblebox/babblebox/api/migrations/0003_chatparticipant_remove_chat_participants_chat_owner_and_more.py b/babblebox/babblebox/api/migrations/0003_chatparticipant_remove_chat_participants_chat_owner_and_more.py new file mode 100644 index 0000000..dd644f7 --- /dev/null +++ b/babblebox/babblebox/api/migrations/0003_chatparticipant_remove_chat_participants_chat_owner_and_more.py @@ -0,0 +1,77 @@ +# Generated by Django 4.2.9 on 2024-03-06 16:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api", "0002_participant_chat_participants_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ChatParticipant", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("has_read_access", models.BooleanField(default=True)), + ("has_write_access", models.BooleanField(default=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ], + ), + migrations.RemoveField( + model_name="chat", + name="participants", + ), + migrations.AddField( + model_name="chat", + name="owner", + field=models.ForeignKey( + default=3, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_owner", + to=settings.AUTH_USER_MODEL, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="chatmessage", + name="owner", + field=models.ForeignKey( + default=3, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_message_owner", + to=settings.AUTH_USER_MODEL, + ), + preserve_default=False, + ), + migrations.DeleteModel( + name="Participant", + ), + migrations.AddField( + model_name="chatparticipant", + name="chat", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="api.chat"), + ), + migrations.AddField( + model_name="chatparticipant", + name="user", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddIndex( + model_name="chatparticipant", + index=models.Index(fields=["chat"], name="chat_participant_idx"), + ), + migrations.AddIndex( + model_name="chatparticipant", + index=models.Index(fields=["user"], name="user_participant_idx"), + ), + migrations.AddIndex( + model_name="chatparticipant", + index=models.Index(fields=["chat", "user"], name="chat_user_participant_idx"), + ), + ] diff --git a/babblebox/babblebox/api/migrations/0004_chat_participants.py b/babblebox/babblebox/api/migrations/0004_chat_participants.py new file mode 100644 index 0000000..edd7c87 --- /dev/null +++ b/babblebox/babblebox/api/migrations/0004_chat_participants.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.9 on 2024-03-06 16:58 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api", "0003_chatparticipant_remove_chat_participants_chat_owner_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="chat", + name="participants", + field=models.ManyToManyField(through="api.ChatParticipant", to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/babblebox/babblebox/api/migrations/0005_chat_is_public.py b/babblebox/babblebox/api/migrations/0005_chat_is_public.py new file mode 100644 index 0000000..bd86fff --- /dev/null +++ b/babblebox/babblebox/api/migrations/0005_chat_is_public.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.9 on 2024-03-06 17:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0004_chat_participants"), + ] + + operations = [ + migrations.AddField( + model_name="chat", + name="is_public", + field=models.BooleanField(default=False), + ), + ] diff --git a/babblebox/babblebox/api/models.py b/babblebox/babblebox/api/models.py index ce4a81f..7fe837f 100644 --- a/babblebox/babblebox/api/models.py +++ b/babblebox/babblebox/api/models.py @@ -51,13 +51,42 @@ def save(self, *args, **kwargs): class Chat(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) topic = models.CharField(max_length=255, editable=True) + is_public = models.BooleanField(default=False, editable=True) + # Add a particpants field to store the participants of the chat that is a mant-to-many relationship with users + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='chat_owner', + editable=False, + null=False, + blank=False + ) + participants = models.ManyToManyField(settings.AUTH_USER_MODEL, through='api.ChatParticipant') def save(self, *args, **kwargs): - if not self.id: - # Generate a unique ID - self.id = str(uuid.uuid4()) super(Chat, self).save(*args, **kwargs) + def is_owner(self, user): + return self.owner == user + + +class ChatParticipant(models.Model): + chat = models.ForeignKey(Chat, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + has_read_access = models.BooleanField(default=True) + has_write_access = models.BooleanField(default=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + indexes = [ + models.Index(fields=['chat'], name='chat_participant_idx'), + models.Index(fields=['user'], name='user_participant_idx'), + models.Index(fields=['chat', 'user'], name='chat_user_participant_idx'), + ] + + @classmethod + def get_participants_for_chat(cls, chat_id): + return cls.objects.filter(chat=chat_id) class ChatMessage(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -65,6 +94,17 @@ class ChatMessage(models.Model): audio_message_id = models.ForeignKey(AudioFile, on_delete=models.CASCADE) timestamp = models.DateTimeField(auto_now_add=True) image_id = models.ForeignKey(ImageFile, on_delete=models.CASCADE, null=True, blank=True) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='chat_message_owner', + editable=False, + null=False, + blank=False + ) + + def is_owner(self, user): + return self.owner == user class Meta: indexes = [ @@ -90,6 +130,5 @@ def get_avro_schema(cls): logger = logging.getLogger(__name__) - logger.info(f'Django DEBUG mode is {"on" if settings.DEBUG else "off"}') -print(ChatMessage.get_avro_schema()) + diff --git a/babblebox/babblebox/api/permissions.py b/babblebox/babblebox/api/permissions.py new file mode 100644 index 0000000..f962c21 --- /dev/null +++ b/babblebox/babblebox/api/permissions.py @@ -0,0 +1,26 @@ +from rest_framework import permissions + +class IsOwnerOrReadOnly(permissions.BasePermission): + """ + Custom permission to only allow owners of an object to edit it. + """ + + def has_object_permission(self, request, view, obj): + # Read permissions are allowed to any request, + # so we'll always allow GET, HEAD or OPTIONS requests. + if request.method in permissions.SAFE_METHODS: + return True + + # Write permissions are only allowed to the owner of the chat. + return obj.owner == request.user + + +class IsParticipantOrOwner(permissions.BasePermission): + """ + Custom permission to only allow participants or the owner of the chat to view it. + """ + + def has_object_permission(self, request, view, obj): + if request.user == obj.owner: + return True + return obj.participants.filter(id=request.user.id).exists() diff --git a/babblebox/babblebox/api/serializers.py b/babblebox/babblebox/api/serializers.py index 65015c2..1bfe91e 100644 --- a/babblebox/babblebox/api/serializers.py +++ b/babblebox/babblebox/api/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import AudioFile, ChatMessage, Chat, ImageFile +from .models import AudioFile, ChatMessage, Chat, ImageFile, ChatParticipant class AudioFileSerializer(serializers.ModelSerializer): @@ -14,11 +14,17 @@ class Meta: fields = ['id', 'image', 'file_location'] +class ParticipantSerializer(serializers.ModelSerializer): + class Meta: + model = ChatParticipant + fields = ['chat', 'user', 'has_read_access', 'has_write_access', 'last_updated'] + + class ChatSerializer(serializers.ModelSerializer): class Meta: model = Chat - fields = ['id', 'topic'] - + fields = ['id', 'topic', 'participants', 'owner', 'is_public'] + read_only_fields = ['owner'] class ChatMessageSerializer(serializers.ModelSerializer): audio_file = AudioFileSerializer(write_only=True) diff --git a/babblebox/babblebox/api/urls.py b/babblebox/babblebox/api/urls.py index ab2a76f..2db916b 100644 --- a/babblebox/babblebox/api/urls.py +++ b/babblebox/babblebox/api/urls.py @@ -1,14 +1,15 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .views import AudioFileViewSet, ChatViewSet, ChatMessageViewSet, ImageFileViewSet +from .views import AudioFileViewSet, ChatViewSet, ChatMessageViewSet, ImageFileViewSet, ParticipantViewSet router = DefaultRouter() router.register(r'AudioFile', AudioFileViewSet) router.register(r'ImageFile', ImageFileViewSet) router.register(r'Chat', ChatViewSet) router.register(r'ChatMessage', ChatMessageViewSet) +router.register(r'Participant', ParticipantViewSet) urlpatterns = [ path('', include(router.urls)), -] \ No newline at end of file +] diff --git a/babblebox/babblebox/api/views.py b/babblebox/babblebox/api/views.py index 2dadf0c..ac4db1d 100644 --- a/babblebox/babblebox/api/views.py +++ b/babblebox/babblebox/api/views.py @@ -1,11 +1,14 @@ from rest_framework import viewsets, status from rest_framework.response import Response +from .permissions import IsOwnerOrReadOnly, IsParticipantOrOwner + from .clients.pulsar_client_avro import PulsarClient from .logging_mixin import LoggingMixin -from .models import AudioFile, ChatMessage, Chat, ImageFile -from .serializers import AudioFileSerializer, ChatMessageSerializer, ChatSerializer, ImageFileSerializer - +from .models import AudioFile, ChatMessage, Chat, ImageFile, ChatParticipant +from .serializers import AudioFileSerializer, ChatMessageSerializer, ChatSerializer, ImageFileSerializer, ParticipantSerializer +from django.db import transaction +from django.db.models import Q class AudioFileViewSet(LoggingMixin, viewsets.ModelViewSet): queryset = AudioFile.objects.all() @@ -16,11 +19,88 @@ class ImageFileViewSet(LoggingMixin, viewsets.ModelViewSet): queryset = ImageFile.objects.all() serializer_class = ImageFileSerializer +class ParticipantViewSet(LoggingMixin, viewsets.ModelViewSet): + queryset = Chat.objects.all() + serializer_class = ChatSerializer + + def get_queryset(self): + """ + Optionally restricts the returned chat messages to a given chat, + by filtering against a `chat_id` query parameter in the URL. + """ + queryset = Chat.objects.all() + chat_id = self.request.query_params.get('chat_id') + if chat_id is not None: + queryset = queryset.filter(chat_id=chat_id) + return queryset + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + chat = serializer.save() + data = serializer.data + data["id"] = str(data["id"]) + return Response(serializer.data, status=status.HTTP_201_CREATED) + +''' +Participant viewset use cases: +- P0 - Each user can view the chat particpants they are part of. +- P0 - Each user can add new participants to chat: they own, they have send message access to, and public chats +- P0 - Owner can remove people from the chat. +- P0 - Users cannot view participants of a chat they are not part of if the chat is not public. + +- For a public chat, user can simply view the chat or join it. If joined we will add entry to the participant table +- User can leave a chat they are part of by deleting the participant entry +''' + +class ParticipantViewSet(LoggingMixin, viewsets.ModelViewSet): + queryset = ChatParticipant.objects.all() + serializer_class = ParticipantSerializer + + def get_queryset(self): + """ + Only return participants for the chat which user is a particpant of. + """ + queryset = ChatParticipant.objects.filter(user=self.request.user) + chat_id = self.request.query_params.get('chat_id') + + if chat_id is not None: + queryset = queryset.filter(chat_id=chat_id) + return queryset class ChatViewSet(viewsets.ModelViewSet): queryset = Chat.objects.all() serializer_class = ChatSerializer + def perform_create(self, serializer): + with transaction.atomic(): + print("Creating chat") + # Save the Chat instance created by the serializer + # Assuming the request includes the owner information. + # You may need to adjust this based on how your owner is determined (e.g., from the request user) + owner = self.request.user + chat = serializer.save(owner=owner) + + # Create a Participant instance for the owner with the necessary flags + ChatParticipant.objects.create(chat=chat, user=owner, has_read_access=True, has_write_access=True) + + + def get_permissions(self): + if self.action in ['update', 'partial_update']: + permission_classes = [IsOwnerOrReadOnly] + elif self.action == 'retrieve': + permission_classes = [IsParticipantOrOwner] + else: + return super().get_permissions() + return [permission() for permission in permission_classes] + + def get_queryset(self): + user = self.request.user + # Filter chats where the user is the owner or a participant + return Chat.objects.filter( + Q(owner=user) | Q(participants=user) + ).distinct() + class ChatMessageViewSet(LoggingMixin, viewsets.ModelViewSet): queryset = ChatMessage.objects.all() diff --git a/babblebox/config/settings/base.py b/babblebox/config/settings/base.py index 3ee2369..6e8bc0c 100644 --- a/babblebox/config/settings/base.py +++ b/babblebox/config/settings/base.py @@ -23,6 +23,7 @@ # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug DEBUG = True +DISABLE_PULSAR_CLIENT = env("DISABLE_PULSAR_CLIENT", default=False) # Local time zone. Choices are # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name diff --git a/babblebox/config/settings/local.py b/babblebox/config/settings/local.py index 129ccaa..5a2e6d4 100644 --- a/babblebox/config/settings/local.py +++ b/babblebox/config/settings/local.py @@ -8,6 +8,7 @@ # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug DEBUG = True + # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key SECRET_KEY = env( "DJANGO_SECRET_KEY",