mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-23 14:59:36 +02:00
More sharing feartures and permissions
This commit is contained in:
parent
f2ba479c8e
commit
0664d9434c
5 changed files with 160 additions and 21 deletions
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -81,6 +81,7 @@ class Collection(models.Model):
|
||||||
end_date = models.DateField(blank=True, null=True)
|
end_date = models.DateField(blank=True, null=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
is_archived = models.BooleanField(default=False)
|
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
|
# if connected adventures are private and collection is public, raise an error
|
||||||
|
|
|
@ -28,3 +28,70 @@ class IsPublicReadOnly(permissions.BasePermission):
|
||||||
|
|
||||||
# Write permissions are only allowed to the owner of the object
|
# Write permissions are only allowed to the owner of the object
|
||||||
return obj.user_id == request.user
|
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 (except delete) 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 (except DELETE) for shared users
|
||||||
|
if request.user in obj.collection.shared_with.all():
|
||||||
|
return request.method != 'DELETE'
|
||||||
|
|
||||||
|
# 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 (except delete) 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 (except DELETE) for shared users
|
||||||
|
if request.user in obj.collection.shared_with.all():
|
||||||
|
return request.method != 'DELETE'
|
||||||
|
|
||||||
|
# Allow all actions for the owner
|
||||||
|
return obj.user_id == request.user
|
|
@ -225,5 +225,14 @@ class CollectionSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Collection
|
model = Collection
|
||||||
# fields are all plus the adventures field
|
# 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']
|
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
|
|
@ -6,11 +6,12 @@ from rest_framework import viewsets
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage
|
from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from worldtravel.models import VisitedRegion, Region, Country
|
from worldtravel.models import VisitedRegion, Region, Country
|
||||||
from .serializers import AdventureImageSerializer, AdventureSerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer
|
from .serializers import AdventureImageSerializer, AdventureSerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from django.db.models import Q, Prefetch
|
from django.db.models import Q, Prefetch
|
||||||
from .permissions import IsOwnerOrReadOnly, IsPublicReadOnly
|
from .permissions import CollectionShared, IsOwnerOrReadOnly, IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess, IsPublicReadOnly
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
@ -28,7 +29,7 @@ from django.db.models import Q
|
||||||
|
|
||||||
class AdventureViewSet(viewsets.ModelViewSet):
|
class AdventureViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = AdventureSerializer
|
serializer_class = AdventureSerializer
|
||||||
permission_classes = [IsOwnerOrReadOnly, IsPublicReadOnly]
|
permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess]
|
||||||
pagination_class = StandardResultsSetPagination
|
pagination_class = StandardResultsSetPagination
|
||||||
|
|
||||||
def apply_sorting(self, queryset):
|
def apply_sorting(self, queryset):
|
||||||
|
@ -71,16 +72,13 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
||||||
if self.action == 'retrieve':
|
if self.action == 'retrieve':
|
||||||
# For individual adventure retrieval, include public adventures
|
# For individual adventure retrieval, include public adventures
|
||||||
return Adventure.objects.filter(
|
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:
|
else:
|
||||||
# For other actions, only include user's own adventures
|
# For other actions, include user's own adventures and shared adventures
|
||||||
return Adventure.objects.filter(user_id=self.request.user.id)
|
return Adventure.objects.filter(
|
||||||
|
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
|
||||||
def list(self, request, *args, **kwargs):
|
)
|
||||||
# Prevent listing all adventures
|
|
||||||
return Response({"detail": "Listing all adventures is not allowed."},
|
|
||||||
status=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
|
@ -100,8 +98,6 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
||||||
adventure.is_public = adventure.collection.is_public
|
adventure.is_public = adventure.collection.is_public
|
||||||
adventure.save()
|
adventure.save()
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
serializer.save(user_id=self.request.user)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=['get'])
|
||||||
def filtered(self, request):
|
def filtered(self, request):
|
||||||
|
@ -196,6 +192,46 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
||||||
serializer = self.get_serializer(queryset, many=True)
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
print(serializer.validated_data)
|
||||||
|
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)
|
||||||
|
self.perform_update(serializer)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||||
|
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)
|
||||||
|
self.perform_update(serializer)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
# Check if the user is the owner or is in the shared_with list
|
||||||
|
if collection.user_id != self.request.user.id and not collection.shared_with.filter(id=self.request.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.")
|
||||||
|
|
||||||
|
# Save the adventure with the current user as the owner
|
||||||
|
serializer.save(user_id=self.request.user.id)
|
||||||
|
|
||||||
def paginate_and_respond(self, queryset, request):
|
def paginate_and_respond(self, queryset, request):
|
||||||
paginator = self.pagination_class()
|
paginator = self.pagination_class()
|
||||||
page = paginator.paginate_queryset(queryset, request)
|
page = paginator.paginate_queryset(queryset, request)
|
||||||
|
@ -207,7 +243,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
class CollectionViewSet(viewsets.ModelViewSet):
|
class CollectionViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = CollectionSerializer
|
serializer_class = CollectionSerializer
|
||||||
permission_classes = [IsOwnerOrReadOnly, IsPublicReadOnly]
|
permission_classes = [CollectionShared]
|
||||||
pagination_class = StandardResultsSetPagination
|
pagination_class = StandardResultsSetPagination
|
||||||
|
|
||||||
# def get_queryset(self):
|
# def get_queryset(self):
|
||||||
|
@ -302,6 +338,8 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||||
action = "public" if new_public_status else "private"
|
action = "public" if new_public_status else "private"
|
||||||
print(f"Collection {instance.id} and its adventures were set to {action}")
|
print(f"Collection {instance.id} and its adventures were set to {action}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
self.perform_update(serializer)
|
self.perform_update(serializer)
|
||||||
|
|
||||||
if getattr(instance, '_prefetched_objects_cache', None):
|
if getattr(instance, '_prefetched_objects_cache', None):
|
||||||
|
@ -316,19 +354,23 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||||
return Collection.objects.filter(user_id=self.request.user.id)
|
return Collection.objects.filter(user_id=self.request.user.id)
|
||||||
|
|
||||||
if self.action in ['update', 'partial_update']:
|
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 self.action == 'retrieve':
|
||||||
return Collection.objects.filter(
|
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(
|
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):
|
def perform_create(self, serializer):
|
||||||
|
# This is ok because you cannot share a collection when creating it
|
||||||
serializer.save(user_id=self.request.user)
|
serializer.save(user_id=self.request.user)
|
||||||
|
|
||||||
def paginate_and_respond(self, queryset, request):
|
def paginate_and_respond(self, queryset, request):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue