diff --git a/README.md b/README.md index 546e95a..1491d6b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ **Documentation can be found [here](https://docs.adventurelog.app).** +**Join the AdventureLog Community Discord Server [here](https://discord.gg/wRbQ9Egr8C).** + # Table of Contents - [Installation](#installation) diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 7ac6842..ab8bfc6 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -46,8 +46,9 @@ from users.models import CustomUser class CustomUserAdmin(UserAdmin): model = CustomUser list_display = ['username', 'email', 'is_staff', 'is_active', 'image_display'] + readonly_fields = ('uuid',) fieldsets = UserAdmin.fieldsets + ( - (None, {'fields': ('profile_pic',)}), + (None, {'fields': ('profile_pic', 'uuid', 'public_profile')}), ) def image_display(self, obj): if obj.profile_pic: diff --git a/backend/server/adventures/migrations/0005_collection_shared_with.py b/backend/server/adventures/migrations/0005_collection_shared_with.py new file mode 100644 index 0000000..c3ee3ac --- /dev/null +++ b/backend/server/adventures/migrations/0005_collection_shared_with.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.8 on 2024-09-02 13:21 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0004_transportation_end_date'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='collection', + name='shared_with', + field=models.ManyToManyField(blank=True, related_name='shared_with', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index a7f3522..79033be 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -81,6 +81,7 @@ class Collection(models.Model): end_date = models.DateField(blank=True, null=True) updated_at = models.DateTimeField(auto_now=True) is_archived = models.BooleanField(default=False) + shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True) # if connected adventures are private and collection is public, raise an error diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py index b4241c2..5f9a911 100644 --- a/backend/server/adventures/permissions.py +++ b/backend/server/adventures/permissions.py @@ -27,4 +27,71 @@ class IsPublicReadOnly(permissions.BasePermission): return obj.is_public or obj.user_id == request.user # Write permissions are only allowed to the owner of the object + return obj.user_id == request.user + +class CollectionShared(permissions.BasePermission): + """ + Custom permission to only allow read-only access to public objects, + and write access to the owner of the object. + """ + + def has_object_permission(self, request, view, obj): + + # Read permissions are allowed if the object is shared with the user + if obj.shared_with and obj.shared_with.filter(id=request.user.id).exists(): + return True + + # Write permissions are allowed if the object is shared with the user + if request.method not in permissions.SAFE_METHODS and obj.shared_with.filter(id=request.user.id).exists(): + return True + + # Read permissions are allowed if the object is public + if request.method in permissions.SAFE_METHODS: + return obj.is_public or obj.user_id == request.user + + # Write permissions are only allowed to the owner of the object + return obj.user_id == request.user + +class IsOwnerOrSharedWithFullAccess(permissions.BasePermission): + """ + Custom permission to allow: + - Full access for shared users + - Full access for owners + - Read-only access for others on safe methods + """ + + def has_object_permission(self, request, view, obj): + # Check if the object has a collection + if hasattr(obj, 'collection') and obj.collection: + # Allow all actions for shared users + if request.user in obj.collection.shared_with.all(): + return True + + # Always allow GET, HEAD, or OPTIONS requests (safe methods) + if request.method in permissions.SAFE_METHODS: + return True + + # Allow all actions for the owner + return obj.user_id == request.user + +class IsPublicOrOwnerOrSharedWithFullAccess(permissions.BasePermission): + """ + Custom permission to allow: + - Read-only access for public objects + - Full access for shared users + - Full access for owners + """ + + def has_object_permission(self, request, view, obj): + # Allow read-only access for public objects + if obj.is_public and request.method in permissions.SAFE_METHODS: + return True + + # Check if the object has a collection + if hasattr(obj, 'collection') and obj.collection: + # Allow all actions for shared users + if request.user in obj.collection.shared_with.all(): + return True + + # Allow all actions for the owner return obj.user_id == request.user \ No newline at end of file diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 9acd0b2..c012976 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -8,19 +8,6 @@ class AdventureImageSerializer(serializers.ModelSerializer): fields = ['id', 'image', 'adventure'] read_only_fields = ['id'] - # def to_representation(self, instance): - # representation = super().to_representation(instance) - - # # Build the full URL for the image - # request = self.context.get('request') - # if request and instance.image: - # public_url = request.build_absolute_uri(instance.image.url) - # else: - # public_url = f"{os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')}/media/{instance.image.name}" - - # representation['image'] = public_url - # return representation - def to_representation(self, instance): representation = super().to_representation(instance) if instance.image: @@ -55,29 +42,6 @@ class TransportationSerializer(serializers.ModelSerializer): ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] - def validate(self, data): - # Check if the collection is public and the transportation is not - collection = data.get('collection') - is_public = data.get('is_public', False) - if collection and collection.is_public and not is_public: - raise serializers.ValidationError( - 'Transportations associated with a public collection must be public.' - ) - - # Check if the user owns the collection - request = self.context.get('request') - if request and collection and collection.user_id != request.user: - raise serializers.ValidationError( - 'Transportations must be associated with collections owned by the same user.' - ) - - return data - - def create(self, validated_data): - # Set the user_id to the current user - validated_data['user_id'] = self.context['request'].user - return super().create(validated_data) - class NoteSerializer(serializers.ModelSerializer): class Meta: @@ -87,29 +51,6 @@ class NoteSerializer(serializers.ModelSerializer): 'is_public', 'collection', 'created_at', 'updated_at' ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] - - def validate(self, data): - # Check if the collection is public and the transportation is not - collection = data.get('collection') - is_public = data.get('is_public', False) - if collection and collection.is_public and not is_public: - raise serializers.ValidationError( - 'Notes associated with a public collection must be public.' - ) - - # Check if the user owns the collection - request = self.context.get('request') - if request and collection and collection.user_id != request.user: - raise serializers.ValidationError( - 'Notes must be associated with collections owned by the same user.' - ) - - return data - - def create(self, validated_data): - # Set the user_id to the current user - validated_data['user_id'] = self.context['request'].user - return super().create(validated_data) class ChecklistItemSerializer(serializers.ModelSerializer): class Meta: @@ -119,29 +60,15 @@ class ChecklistItemSerializer(serializers.ModelSerializer): ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'checklist'] - def validate(self, data): - # Check if the checklist is public and the checklist item is not - checklist = data.get('checklist') - is_checked = data.get('is_checked', False) - if checklist and checklist.is_public and not is_checked: - raise serializers.ValidationError( - 'Checklist items associated with a public checklist must be checked.' - ) - - # Check if the user owns the checklist - request = self.context.get('request') - if request and checklist and checklist.user_id != request.user: - raise serializers.ValidationError( - 'Checklist items must be associated with checklists owned by the same user.' - ) - - return data - - def create(self, validated_data): - # Set the user_id to the current user - validated_data['user_id'] = self.context['request'].user - return super().create(validated_data) - + # def validate(self, data): + # # Check if the checklist is public and the checklist item is not + # checklist = data.get('checklist') + # is_checked = data.get('is_checked', False) + # if checklist and checklist.is_public and not is_checked: + # raise serializers.ValidationError( + # 'Checklist items associated with a public checklist must be checked.' + # ) + class ChecklistSerializer(serializers.ModelSerializer): items = ChecklistItemSerializer(many=True, source='checklistitem_set') @@ -204,13 +131,6 @@ class ChecklistSerializer(serializers.ModelSerializer): 'Checklists associated with a public collection must be public.' ) - # Check if the user owns the checklist - request = self.context.get('request') - if request and collection and collection.user_id != request.user: - raise serializers.ValidationError( - 'Checklists must be associated with collections owned by the same user.' - ) - return data @@ -225,5 +145,14 @@ class CollectionSerializer(serializers.ModelSerializer): class Meta: model = Collection # fields are all plus the adventures field - fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived'] + fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with'] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] + + def to_representation(self, instance): + representation = super().to_representation(instance) + # Make it display the user uuid for the shared users instead of the PK + shared_uuids = [] + for user in instance.shared_with.all(): + shared_uuids.append(str(user.uuid)) + representation['shared_with'] = shared_uuids + return representation \ No newline at end of file diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index ba69054..7d2a136 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -6,14 +6,18 @@ from rest_framework import viewsets from django.db.models.functions import Lower from rest_framework.response import Response from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage +from django.core.exceptions import PermissionDenied from worldtravel.models import VisitedRegion, Region, Country from .serializers import AdventureImageSerializer, AdventureSerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer from rest_framework.permissions import IsAuthenticated -from django.db.models import Q, Prefetch -from .permissions import IsOwnerOrReadOnly, IsPublicReadOnly +from django.db.models import Q +from .permissions import CollectionShared, IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess from rest_framework.pagination import PageNumberPagination from django.shortcuts import get_object_or_404 from rest_framework import status +from django.contrib.auth import get_user_model + +User = get_user_model() class StandardResultsSetPagination(PageNumberPagination): page_size = 25 @@ -28,7 +32,7 @@ from django.db.models import Q class AdventureViewSet(viewsets.ModelViewSet): serializer_class = AdventureSerializer - permission_classes = [IsOwnerOrReadOnly, IsPublicReadOnly] + permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess] pagination_class = StandardResultsSetPagination def apply_sorting(self, queryset): @@ -68,41 +72,36 @@ class AdventureViewSet(viewsets.ModelViewSet): return queryset.order_by(ordering) def get_queryset(self): + # if the user is not authenticated return only public adventures for retrieve action + if not self.request.user.is_authenticated: + if self.action == 'retrieve': + return Adventure.objects.filter(is_public=True) + return Adventure.objects.none() + + if self.action == 'retrieve': # For individual adventure retrieval, include public adventures return Adventure.objects.filter( - Q(is_public=True) | Q(user_id=self.request.user.id) + Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) ) else: - # For other actions, only include user's own adventures - return Adventure.objects.filter(user_id=self.request.user.id) - - def list(self, request, *args, **kwargs): - # Prevent listing all adventures - return Response({"detail": "Listing all adventures is not allowed."}, - status=status.HTTP_403_FORBIDDEN) + # For other actions, include user's own adventures and shared adventures + return Adventure.objects.filter( + Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) + ) def retrieve(self, request, *args, **kwargs): queryset = self.get_queryset() adventure = get_object_or_404(queryset, pk=kwargs['pk']) serializer = self.get_serializer(adventure) return Response(serializer.data) - - def perform_create(self, serializer): - adventure = serializer.save(user_id=self.request.user) - if adventure.collection: - adventure.is_public = adventure.collection.is_public - adventure.save() def perform_update(self, serializer): adventure = serializer.save() if adventure.collection: adventure.is_public = adventure.collection.is_public adventure.save() - - def perform_create(self, serializer): - serializer.save(user_id=self.request.user) - + @action(detail=False, methods=['get']) def filtered(self, request): types = request.query_params.get('types', '').split(',') @@ -195,6 +194,100 @@ class AdventureViewSet(viewsets.ModelViewSet): queryset = self.apply_sorting(queryset) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + + def update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # if the adventure is trying to have is_public changed and its part of a collection return an error + if new_collection is not None: + serializer.validated_data['is_public'] = new_collection.is_public + elif instance.collection: + serializer.validated_data['is_public'] = instance.collection.is_public + + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + # if the adventure is trying to have is_public changed and its part of a collection return an error + if new_collection is not None: + serializer.validated_data['is_public'] = new_collection.is_public + elif instance.collection: + serializer.validated_data['is_public'] = instance.collection.is_public + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def perform_update(self, serializer): + serializer.save() + + # when creating an adventure, make sure the user is the owner of the collection or shared with the collection + def perform_create(self, serializer): + # Retrieve the collection from the validated data + collection = serializer.validated_data.get('collection') + + # Check if a collection is provided + if collection: + user = self.request.user + # Check if the user is the owner or is in the shared_with list + if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): + # Return an error response if the user does not have permission + raise PermissionDenied("You do not have permission to use this collection.") + # if collection the owner of the adventure is the owner of the collection + # set the is_public field of the adventure to the is_public field of the collection + serializer.save(user_id=collection.user_id, is_public=collection.is_public) + return + + # Save the adventure with the current user as the owner + serializer.save(user_id=self.request.user) def paginate_and_respond(self, queryset, request): paginator = self.pagination_class() @@ -207,7 +300,7 @@ class AdventureViewSet(viewsets.ModelViewSet): class CollectionViewSet(viewsets.ModelViewSet): serializer_class = CollectionSerializer - permission_classes = [IsOwnerOrReadOnly, IsPublicReadOnly] + permission_classes = [CollectionShared] pagination_class = StandardResultsSetPagination # def get_queryset(self): @@ -244,7 +337,7 @@ class CollectionViewSet(viewsets.ModelViewSet): # make sure the user is authenticated if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - queryset = self.get_queryset() + queryset = Collection.objects.filter(user_id=request.user.id) queryset = self.apply_sorting(queryset) collections = self.paginate_and_respond(queryset, request) return collections @@ -285,10 +378,21 @@ class CollectionViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(instance, data=request.data, partial=partial) serializer.is_valid(raise_exception=True) + if 'collection' in serializer.validated_data: + new_collection = serializer.validated_data['collection'] + # if the new collection is different from the old one and the user making the request is not the owner of the new collection return an error + if new_collection != instance.collection and new_collection.user_id != request.user: + return Response({"error": "User does not own the new collection"}, status=400) + # Check if the 'is_public' field is present in the update data if 'is_public' in serializer.validated_data: new_public_status = serializer.validated_data['is_public'] + # if is_publuc has changed and the user is not the owner of the collection return an error + if new_public_status != instance.is_public and instance.user_id != request.user: + print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user_id}") + return Response({"error": "User does not own the collection"}, status=400) + # Update associated adventures to match the collection's is_public status Adventure.objects.filter(collection=instance).update(is_public=new_public_status) @@ -310,25 +414,86 @@ class CollectionViewSet(viewsets.ModelViewSet): instance._prefetched_objects_cache = {} return Response(serializer.data) + + # make an action to retreive all adventures that are shared with the user + @action(detail=False, methods=['get']) + def shared(self, request): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + queryset = Collection.objects.filter( + shared_with=request.user + ) + queryset = self.apply_sorting(queryset) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + # Adds a new user to the shared_with field of an adventure + @action(detail=True, methods=['post'], url_path='share/(?P[^/.]+)') + def share(self, request, pk=None, uuid=None): + collection = self.get_object() + if not uuid: + return Response({"error": "User UUID is required"}, status=400) + try: + user = User.objects.get(uuid=uuid, public_profile=True) + except User.DoesNotExist: + return Response({"error": "User not found"}, status=404) + + if user == request.user: + return Response({"error": "Cannot share with yourself"}, status=400) + + if collection.shared_with.filter(id=user.id).exists(): + return Response({"error": "Adventure is already shared with this user"}, status=400) + + collection.shared_with.add(user) + collection.save() + return Response({"success": f"Shared with {user.username}"}) + + @action(detail=True, methods=['post'], url_path='unshare/(?P[^/.]+)') + def unshare(self, request, pk=None, uuid=None): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + collection = self.get_object() + if not uuid: + return Response({"error": "User UUID is required"}, status=400) + try: + user = User.objects.get(uuid=uuid, public_profile=True) + except User.DoesNotExist: + return Response({"error": "User not found"}, status=404) + + if user == request.user: + return Response({"error": "Cannot unshare with yourself"}, status=400) + + if not collection.shared_with.filter(id=user.id).exists(): + return Response({"error": "Collection is not shared with this user"}, status=400) + + collection.shared_with.remove(user) + collection.save() + return Response({"success": f"Unshared with {user.username}"}) def get_queryset(self): if self.action == 'destroy': return Collection.objects.filter(user_id=self.request.user.id) if self.action in ['update', 'partial_update']: - return Collection.objects.filter(user_id=self.request.user.id) + return Collection.objects.filter( + Q(user_id=self.request.user.id) | Q(shared_with=self.request.user) + ).distinct() if self.action == 'retrieve': + if not self.request.user.is_authenticated: + return Collection.objects.filter(is_public=True) return Collection.objects.filter( - Q(is_public=True) | Q(user_id=self.request.user.id) - ) + Q(is_public=True) | Q(user_id=self.request.user.id) | Q(shared_with=self.request.user) + ).distinct() - # For other actions (like list), only include user's non-archived collections + # For list action, include collections owned by the user or shared with the user, that are not archived return Collection.objects.filter( - Q(user_id=self.request.user.id) & Q(is_archived=False) - ) + (Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)) & Q(is_archived=False) + ).distinct() + def perform_create(self, serializer): + # This is ok because you cannot share a collection when creating it serializer.save(user_id=self.request.user) def paginate_and_respond(self, queryset, request): @@ -430,7 +595,7 @@ class ActivityTypesView(viewsets.ViewSet): class TransportationViewSet(viewsets.ModelViewSet): queryset = Transportation.objects.all() serializer_class = TransportationSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess] filterset_fields = ['type', 'is_public', 'collection'] # return error message if user is not authenticated on the root endpoint @@ -451,21 +616,108 @@ class TransportationViewSet(viewsets.ModelViewSet): def get_queryset(self): - - """ - This view should return a list of all transportations - for the currently authenticated user. - """ - user = self.request.user - return Transportation.objects.filter(user_id=user) + # if the user is not authenticated return only public transportations for retrieve action + if not self.request.user.is_authenticated: + if self.action == 'retrieve': + return Transportation.objects.filter(is_public=True) + return Transportation.objects.none() + + if self.action == 'retrieve': + # For individual adventure retrieval, include public adventures + return Transportation.objects.filter( + Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) + ) + else: + # For other actions, include user's own adventures and shared adventures + return Transportation.objects.filter( + Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) + ) + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def perform_update(self, serializer): + serializer.save() + + # when creating an adventure, make sure the user is the owner of the collection or shared with the collection def perform_create(self, serializer): + # Retrieve the collection from the validated data + collection = serializer.validated_data.get('collection') + + # Check if a collection is provided + if collection: + user = self.request.user + # Check if the user is the owner or is in the shared_with list + if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): + # Return an error response if the user does not have permission + raise PermissionDenied("You do not have permission to use this collection.") + # if collection the owner of the adventure is the owner of the collection + serializer.save(user_id=collection.user_id) + return + + # Save the adventure with the current user as the owner serializer.save(user_id=self.request.user) class NoteViewSet(viewsets.ModelViewSet): queryset = Note.objects.all() serializer_class = NoteSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess] filterset_fields = ['is_public', 'collection'] # return error message if user is not authenticated on the root endpoint @@ -486,21 +738,108 @@ class NoteViewSet(viewsets.ModelViewSet): def get_queryset(self): - - """ - This view should return a list of all notes - for the currently authenticated user. - """ - user = self.request.user - return Note.objects.filter(user_id=user) + # if the user is not authenticated return only public transportations for retrieve action + if not self.request.user.is_authenticated: + if self.action == 'retrieve': + return Note.objects.filter(is_public=True) + return Note.objects.none() + + if self.action == 'retrieve': + # For individual adventure retrieval, include public adventures + return Note.objects.filter( + Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) + ) + else: + # For other actions, include user's own adventures and shared adventures + return Note.objects.filter( + Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) + ) + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def perform_update(self, serializer): + serializer.save() + + # when creating an adventure, make sure the user is the owner of the collection or shared with the collection def perform_create(self, serializer): + # Retrieve the collection from the validated data + collection = serializer.validated_data.get('collection') + + # Check if a collection is provided + if collection: + user = self.request.user + # Check if the user is the owner or is in the shared_with list + if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): + # Return an error response if the user does not have permission + raise PermissionDenied("You do not have permission to use this collection.") + # if collection the owner of the adventure is the owner of the collection + serializer.save(user_id=collection.user_id) + return + + # Save the adventure with the current user as the owner serializer.save(user_id=self.request.user) class ChecklistViewSet(viewsets.ModelViewSet): queryset = Checklist.objects.all() serializer_class = ChecklistSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess] filterset_fields = ['is_public', 'collection'] # return error message if user is not authenticated on the root endpoint @@ -521,15 +860,102 @@ class ChecklistViewSet(viewsets.ModelViewSet): def get_queryset(self): - - """ - This view should return a list of all checklists - for the currently authenticated user. - """ - user = self.request.user - return Checklist.objects.filter(user_id=user) + # if the user is not authenticated return only public transportations for retrieve action + if not self.request.user.is_authenticated: + if self.action == 'retrieve': + return Checklist.objects.filter(is_public=True) + return Checklist.objects.none() + + if self.action == 'retrieve': + # For individual adventure retrieval, include public adventures + return Checklist.objects.filter( + Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) + ) + else: + # For other actions, include user's own adventures and shared adventures + return Checklist.objects.filter( + Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) + ) + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def perform_update(self, serializer): + serializer.save() + + # when creating an adventure, make sure the user is the owner of the collection or shared with the collection def perform_create(self, serializer): + # Retrieve the collection from the validated data + collection = serializer.validated_data.get('collection') + + # Check if a collection is provided + if collection: + user = self.request.user + # Check if the user is the owner or is in the shared_with list + if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): + # Return an error response if the user does not have permission + raise PermissionDenied("You do not have permission to use this collection.") + # if collection the owner of the adventure is the owner of the collection + serializer.save(user_id=collection.user_id) + return + + # Save the adventure with the current user as the owner serializer.save(user_id=self.request.user) class AdventureImageViewSet(viewsets.ModelViewSet): @@ -555,7 +981,13 @@ class AdventureImageViewSet(viewsets.ModelViewSet): return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) if adventure.user_id != request.user: - return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) + # Check if the adventure has a collection + if adventure.collection: + # Check if the user is in the collection's shared_with list + if not adventure.collection.shared_with.filter(id=request.user.id).exists(): + return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN) + else: + return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) return super().create(request, *args, **kwargs) diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index e9eb1f7..fe098bd 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -4,7 +4,7 @@ from django.views.generic import RedirectView, TemplateView from django.conf import settings from django.conf.urls.static import static from adventures import urls as adventures -from users.views import ChangeEmailView, IsRegistrationDisabled +from users.views import ChangeEmailView, IsRegistrationDisabled, PublicUserListView, PublicUserDetailView from .views import get_csrf_token from drf_yasg.views import get_schema_view @@ -22,6 +22,8 @@ urlpatterns = [ path('auth/change-email/', ChangeEmailView.as_view(), name='change_email'), path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'), + path('auth/users/', PublicUserListView.as_view(), name='public-user-list'), + path('auth/user//', PublicUserDetailView.as_view(), name='public-user-detail'), path('csrf/', get_csrf_token, name='get_csrf_token'), re_path(r'^$', TemplateView.as_view( diff --git a/backend/server/users/migrations/0002_customuser_public_profile.py b/backend/server/users/migrations/0002_customuser_public_profile.py new file mode 100644 index 0000000..e8ba313 --- /dev/null +++ b/backend/server/users/migrations/0002_customuser_public_profile.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2024-09-06 23:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='public_profile', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/server/users/models.py b/backend/server/users/models.py index d1fd990..fa28f1c 100644 --- a/backend/server/users/models.py +++ b/backend/server/users/models.py @@ -6,6 +6,7 @@ from django_resized import ResizedImageField class CustomUser(AbstractUser): profile_pic = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='profile-pics/') uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + public_profile = models.BooleanField(default=False) def __str__(self): return self.username \ No newline at end of file diff --git a/backend/server/users/serializers.py b/backend/server/users/serializers.py index d9628ba..f7e4df6 100644 --- a/backend/server/users/serializers.py +++ b/backend/server/users/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from django.contrib.auth import get_user_model -from adventures.models import Adventure +from adventures.models import Adventure, Collection from users.forms import CustomAllAuthPasswordResetForm from dj_rest_auth.serializers import PasswordResetSerializer from rest_framework.exceptions import PermissionDenied @@ -133,8 +133,6 @@ class UserDetailsSerializer(serializers.ModelSerializer): @staticmethod def validate_username(username): if 'allauth.account' not in settings.INSTALLED_APPS: - # We don't need to call the all-auth - # username validator unless it's installed return username from allauth.account.adapter import get_adapter @@ -142,12 +140,9 @@ class UserDetailsSerializer(serializers.ModelSerializer): return username class Meta: - extra_fields = ['profile_pic'] + extra_fields = ['profile_pic', 'uuid', 'public_profile'] profile_pic = serializers.ImageField(required=False) - # see https://github.com/iMerica/dj-rest-auth/issues/181 - # UserModel.XYZ causing attribute error while importing other - # classes from `serializers.py`. So, we need to check whether the auth model has - # the attribute or not + if hasattr(UserModel, 'USERNAME_FIELD'): extra_fields.append(UserModel.USERNAME_FIELD) if hasattr(UserModel, 'EMAIL_FIELD'): @@ -160,21 +155,38 @@ class UserDetailsSerializer(serializers.ModelSerializer): extra_fields.append('date_joined') if hasattr(UserModel, 'is_staff'): extra_fields.append('is_staff') + if hasattr(UserModel, 'public_profile'): + extra_fields.append('public_profile') class Meta(UserDetailsSerializer.Meta): model = CustomUser - fields = UserDetailsSerializer.Meta.fields + ('profile_pic',) - + fields = UserDetailsSerializer.Meta.fields + ('profile_pic', 'uuid', 'public_profile') + model = UserModel fields = ('pk', *extra_fields) read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk') + def handle_public_profile_change(self, instance, validated_data): + """Remove user from `shared_with` if public profile is set to False.""" + if 'public_profile' in validated_data and not validated_data['public_profile']: + for collection in Collection.objects.filter(shared_with=instance): + collection.shared_with.remove(instance) + + def update(self, instance, validated_data): + self.handle_public_profile_change(instance, validated_data) + return super().update(instance, validated_data) + + def partial_update(self, instance, validated_data): + self.handle_public_profile_change(instance, validated_data) + return super().partial_update(instance, validated_data) + + class CustomUserDetailsSerializer(UserDetailsSerializer): class Meta(UserDetailsSerializer.Meta): model = CustomUser - fields = UserDetailsSerializer.Meta.fields + ('profile_pic',) + fields = UserDetailsSerializer.Meta.fields + ('profile_pic', 'uuid', 'public_profile') def to_representation(self, instance): representation = super().to_representation(instance) diff --git a/backend/server/users/views.py b/backend/server/users/views.py index 2c8021d..5300d8c 100644 --- a/backend/server/users/views.py +++ b/backend/server/users/views.py @@ -6,6 +6,11 @@ from .serializers import ChangeEmailSerializer from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from django.conf import settings +from django.shortcuts import get_object_or_404 +from django.contrib.auth import get_user_model +from .serializers import CustomUserDetailsSerializer as PublicUserSerializer + +User = get_user_model() class ChangeEmailView(APIView): permission_classes = [IsAuthenticated] @@ -41,4 +46,40 @@ class IsRegistrationDisabled(APIView): ) def get(self, request): return Response({"is_disabled": settings.DISABLE_REGISTRATION, "message": settings.DISABLE_REGISTRATION_MESSAGE}, status=status.HTTP_200_OK) - \ No newline at end of file + +class PublicUserListView(APIView): + # Allow the listing of all public users + permission_classes = [] + + @swagger_auto_schema( + responses={ + 200: openapi.Response('List of public users'), + 400: 'Bad Request' + }, + operation_description="List public users." + ) + def get(self, request): + users = User.objects.filter(public_profile=True).exclude(id=request.user.id) + # remove the email addresses from the response + for user in users: + user.email = None + serializer = PublicUserSerializer(users, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + +class PublicUserDetailView(APIView): + # Allow the retrieval of a single public user + permission_classes = [] + + @swagger_auto_schema( + responses={ + 200: openapi.Response('Public user information'), + 400: 'Bad Request' + }, + operation_description="Get public user information." + ) + def get(self, request, user_id): + user = get_object_or_404(User, uuid=user_id, public_profile=True) + # remove the email address from the response + user.email = None + serializer = PublicUserSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/deploy.sh b/deploy.sh index 9548c7c..e2372e7 100644 --- a/deploy.sh +++ b/deploy.sh @@ -4,4 +4,5 @@ echo "Deploying latest version of AdventureLog" docker compose pull echo "Stating containers" docker compose up -d -echo "All set!" \ No newline at end of file +echo "All set!" +docker logs adventurelog-backend --follow \ No newline at end of file diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index ab72576..fe9063d 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -13,6 +13,8 @@ declare global { date_joined: string | null; is_staff: boolean; profile_pic: string | null; + uuid: string; + public_profile: boolean; } | null; } // interface PageData {} diff --git a/frontend/src/lib/components/AdventureCard.svelte b/frontend/src/lib/components/AdventureCard.svelte index 3264ae1..d72fdfa 100644 --- a/frontend/src/lib/components/AdventureCard.svelte +++ b/frontend/src/lib/components/AdventureCard.svelte @@ -1,7 +1,7 @@
@@ -51,10 +52,10 @@ - - {#if checklist.user_id == user?.pk} + {#if checklist.user_id == user?.pk || (collection && user && collection.shared_with.includes(user.uuid))}
-
{collection.is_public ? 'Public' : 'Private'}
+
{collection.is_public ? 'Public' : 'Private'}
{#if collection.is_archived}
Archived
{/if} @@ -117,7 +123,7 @@ {:else}