mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-19 12:59:36 +02:00
Allow for Sharing of Collections to other Public Users
This commit is contained in:
commit
4a293798eb
47 changed files with 1368 additions and 311 deletions
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
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
|
||||
|
|
|
@ -28,3 +28,70 @@ class IsPublicReadOnly(permissions.BasePermission):
|
|||
|
||||
# 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
|
|
@ -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:
|
||||
|
@ -88,29 +52,6 @@ class NoteSerializer(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(
|
||||
'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:
|
||||
model = ChecklistItem
|
||||
|
@ -119,28 +60,14 @@ 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):
|
||||
|
@ -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
|
|
@ -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,19 +72,23 @@ 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()
|
||||
|
@ -88,21 +96,12 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
|||
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(',')
|
||||
|
@ -196,6 +195,100 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
|||
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()
|
||||
page = paginator.paginate_queryset(queryset, request)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -311,24 +415,85 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
|||
|
||||
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<uuid>[^/.]+)')
|
||||
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<uuid>[^/.]+)')
|
||||
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):
|
||||
# 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()
|
||||
|
||||
"""
|
||||
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 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):
|
||||
# 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()
|
||||
|
||||
"""
|
||||
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 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):
|
||||
# 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()
|
||||
|
||||
"""
|
||||
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 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,6 +981,12 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
|||
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if adventure.user_id != request.user:
|
||||
# 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)
|
||||
|
|
|
@ -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/<uuid:user_id>/', PublicUserDetailView.as_view(), name='public-user-detail'),
|
||||
|
||||
path('csrf/', get_csrf_token, name='get_csrf_token'),
|
||||
re_path(r'^$', TemplateView.as_view(
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
|
@ -42,3 +47,39 @@ class IsRegistrationDisabled(APIView):
|
|||
def get(self, request):
|
||||
return Response({"is_disabled": settings.DISABLE_REGISTRATION, "message": settings.DISABLE_REGISTRATION_MESSAGE}, status=status.HTTP_200_OK)
|
||||
|
||||
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)
|
||||
|
|
|
@ -5,3 +5,4 @@ docker compose pull
|
|||
echo "Stating containers"
|
||||
docker compose up -d
|
||||
echo "All set!"
|
||||
docker logs adventurelog-backend --follow
|
2
frontend/src/app.d.ts
vendored
2
frontend/src/app.d.ts
vendored
|
@ -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 {}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Adventure, User } from '$lib/types';
|
||||
import type { Adventure, Collection, User } from '$lib/types';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
import Launch from '~icons/mdi/launch';
|
||||
|
@ -21,8 +21,8 @@
|
|||
import ImageDisplayModal from './ImageDisplayModal.svelte';
|
||||
|
||||
export let type: string;
|
||||
|
||||
export let user: User | null;
|
||||
export let collection: Collection | null = null;
|
||||
|
||||
let isCollectionModalOpen: boolean = false;
|
||||
let isWarningModalOpen: boolean = false;
|
||||
|
@ -161,7 +161,7 @@
|
|||
{/if}
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl text-base-content"
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
|
||||
>
|
||||
<figure>
|
||||
{#if adventure.images && adventure.images.length > 0}
|
||||
|
@ -209,19 +209,17 @@
|
|||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{#if adventure.type == 'visited' && user?.pk == adventure.user_id}
|
||||
{#if adventure.type == 'visited'}
|
||||
<div class="badge badge-primary">Visited</div>
|
||||
{:else if user?.pk == adventure.user_id && adventure.type == 'planned'}
|
||||
{:else if adventure.type == 'planned'}
|
||||
<div class="badge badge-secondary">Planned</div>
|
||||
{:else if (user?.pk !== adventure.user_id && adventure.type == 'planned') || adventure.type == 'visited'}
|
||||
<div class="badge badge-secondary">Adventure</div>
|
||||
{:else if user?.pk == adventure.user_id && adventure.type == 'lodging'}
|
||||
{:else if adventure.type == 'lodging'}
|
||||
<div class="badge badge-success">Lodging</div>
|
||||
{:else if adventure.type == 'dining'}
|
||||
<div class="badge badge-accent">Dining</div>
|
||||
{/if}
|
||||
|
||||
<div class="badge badge-neutral">{adventure.is_public ? 'Public' : 'Private'}</div>
|
||||
<div class="badge badge-secondary">{adventure.is_public ? 'Public' : 'Private'}</div>
|
||||
</div>
|
||||
{#if adventure.location && adventure.location !== ''}
|
||||
<div class="inline-flex items-center">
|
||||
|
@ -254,9 +252,9 @@
|
|||
<div class="card-actions justify-end mt-2">
|
||||
<!-- action options dropdown -->
|
||||
{#if type != 'link'}
|
||||
{#if user?.pk == adventure.user_id}
|
||||
{#if adventure.user_id == user?.pk || (collection && user && collection.shared_with.includes(user.uuid))}
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-neutral">
|
||||
<div tabindex="0" role="button" class="btn btn-neutral-200">
|
||||
<DotsHorizontal class="w-6 h-6" />
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
|
@ -272,18 +270,18 @@
|
|||
<button class="btn btn-neutral mb-2" on:click={editAdventure}>
|
||||
<FileDocumentEdit class="w-6 h-6" />Edit {keyword}
|
||||
</button>
|
||||
{#if adventure.type == 'visited'}
|
||||
{#if adventure.type == 'visited' && user?.pk == adventure.user_id}
|
||||
<button class="btn btn-neutral mb-2" on:click={changeType('planned')}
|
||||
><FormatListBulletedSquare class="w-6 h-6" />Change to Plan</button
|
||||
>
|
||||
{/if}
|
||||
{#if adventure.type == 'planned'}
|
||||
{#if adventure.type == 'planned' && user?.pk == adventure.user_id}
|
||||
<button class="btn btn-neutral mb-2" on:click={changeType('visited')}
|
||||
><CheckBold class="w-6 h-6" />Mark Visited</button
|
||||
>
|
||||
{/if}
|
||||
<!-- remove from adventure -->
|
||||
{#if adventure.collection && (adventure.type == 'visited' || adventure.type == 'planned')}
|
||||
{#if adventure.collection && (adventure.type == 'visited' || adventure.type == 'planned') && user?.pk == adventure.user_id}
|
||||
<button class="btn btn-neutral mb-2" on:click={removeFromCollection}
|
||||
><LinkVariantRemove class="w-6 h-6" />Remove from Collection</button
|
||||
>
|
||||
|
@ -309,8 +307,9 @@
|
|||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="btn btn-neutral mb-2" on:click={() => goto(`/adventures/${adventure.id}`)}
|
||||
><Launch class="w-6 h-6" /></button
|
||||
<button
|
||||
class="btn btn-neutral-200 mb-2"
|
||||
on:click={() => goto(`/adventures/${adventure.id}`)}><Launch class="w-6 h-6" /></button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
@ -627,6 +627,7 @@
|
|||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !collection_id}
|
||||
<div>
|
||||
<div class="mt-2">
|
||||
<div>
|
||||
|
@ -643,6 +644,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<h2 class="text-2xl font-semibold mb-2 mt-2">Location Information</h2>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
<div class="dropdown dropdown-bottom dropdown-end" tabindex="0" role="button">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-10 ml-4">
|
||||
<div class="bg-neutral rounded-full text-neutral-200 w-10 ml-4">
|
||||
{#if user.profile_pic}
|
||||
<img src={user.profile_pic} alt="User Profile" />
|
||||
{:else}
|
||||
|
@ -24,7 +24,7 @@
|
|||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content z-[1] menu p-2 shadow bg-primary-content mt-2 rounded-box w-52"
|
||||
class="dropdown-content z-[1] text-neutral-200 menu p-2 shadow bg-neutral mt-2 rounded-box w-52"
|
||||
>
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
|
@ -32,6 +32,7 @@
|
|||
<li><button on:click={() => goto('/profile')}>Profile</button></li>
|
||||
<li><button on:click={() => goto('/adventures')}>My Adventures</button></li>
|
||||
<li><button on:click={() => goto('/activities')}>My Activities</button></li>
|
||||
<li><button on:click={() => goto('/shared')}>Shared With Me</button></li>
|
||||
<li><button on:click={() => goto('/settings')}>User Settings</button></li>
|
||||
<form method="post">
|
||||
<li><button formaction="/?/logout">Logout</button></li>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { addToast } from '$lib/toasts';
|
||||
import type { Checklist, User } from '$lib/types';
|
||||
import type { Checklist, Collection, User } from '$lib/types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
@ -10,6 +10,7 @@
|
|||
|
||||
export let checklist: Checklist;
|
||||
export let user: User | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
|
||||
function editChecklist() {
|
||||
dispatch('edit', checklist);
|
||||
|
@ -29,7 +30,7 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl overflow-hidden text-base-content"
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between">
|
||||
|
@ -51,10 +52,10 @@
|
|||
<!-- <button class="btn btn-neutral mb-2" on:click={() => goto(`/notes/${note.id}`)}
|
||||
><Launch class="w-6 h-6" />Open Details</button
|
||||
> -->
|
||||
<button class="btn btn-neutral mb-2" on:click={editChecklist}>
|
||||
<button class="btn btn-neutral-200 mb-2" on:click={editChecklist}>
|
||||
<Launch class="w-6 h-6" />Open
|
||||
</button>
|
||||
{#if checklist.user_id == user?.pk}
|
||||
{#if checklist.user_id == user?.pk || (collection && user && collection.shared_with.includes(user.uuid))}
|
||||
<button
|
||||
id="delete_adventure"
|
||||
data-umami-event="Delete Checklist"
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
{
|
||||
name: newItem,
|
||||
is_checked: newStatus,
|
||||
id: 0,
|
||||
id: '',
|
||||
user_id: 0,
|
||||
checklist: 0,
|
||||
created_at: '',
|
||||
|
@ -135,7 +135,7 @@
|
|||
<p class="font-semibold text-md mb-2">Editing checklist {initialName}</p>
|
||||
{/if}
|
||||
|
||||
{#if (checklist && user?.pk == checklist?.user_id) || !checklist}
|
||||
{#if (checklist && user?.pk == checklist?.user_id) || (user && collection && collection.shared_with.includes(user.uuid)) || !checklist}
|
||||
<form on:submit|preventDefault>
|
||||
<div class="form-control mb-2">
|
||||
<label for="name">Name</label>
|
||||
|
|
|
@ -16,10 +16,12 @@
|
|||
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||
import TrashCan from '~icons/mdi/trashcan';
|
||||
import DeleteWarning from './DeleteWarning.svelte';
|
||||
import ShareModal from './ShareModal.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let type: String | undefined | null;
|
||||
let isShareModalOpen: boolean = false;
|
||||
|
||||
// export let type: String;
|
||||
|
||||
|
@ -77,8 +79,12 @@
|
|||
/>
|
||||
{/if}
|
||||
|
||||
{#if isShareModalOpen}
|
||||
<ShareModal {collection} on:close={() => (isShareModalOpen = false)} />
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="card min-w-max lg:w-96 md:w-80 sm:w-60 xs:w-40 bg-primary-content shadow-xl text-base-content"
|
||||
class="card min-w-max lg:w-96 md:w-80 sm:w-60 xs:w-40 bg-neutral text-neutral-content shadow-xl"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between">
|
||||
|
@ -90,7 +96,7 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="inline-flex gap-2 mb-2">
|
||||
<div class="badge badge-neutral">{collection.is_public ? 'Public' : 'Private'}</div>
|
||||
<div class="badge badge-secondary">{collection.is_public ? 'Public' : 'Private'}</div>
|
||||
{#if collection.is_archived}
|
||||
<div class="badge badge-warning">Archived</div>
|
||||
{/if}
|
||||
|
@ -117,7 +123,7 @@
|
|||
</button>
|
||||
{:else}
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-neutral">
|
||||
<div tabindex="0" role="button" class="btn btn-neutral-200">
|
||||
<DotsHorizontal class="w-6 h-6" />
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
|
@ -125,7 +131,7 @@
|
|||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
|
||||
>
|
||||
{#if type != 'link'}
|
||||
{#if type != 'link' && type != 'viewonly'}
|
||||
<button
|
||||
class="btn btn-neutral mb-2"
|
||||
on:click={() => goto(`/collections/${collection.id}`)}
|
||||
|
@ -135,6 +141,9 @@
|
|||
<button class="btn btn-neutral mb-2" on:click={editAdventure}>
|
||||
<FileDocumentEdit class="w-6 h-6" />Edit Collection
|
||||
</button>
|
||||
<button class="btn btn-neutral mb-2" on:click={() => (isShareModalOpen = true)}>
|
||||
<FileDocumentEdit class="w-6 h-6" />Share
|
||||
</button>
|
||||
{/if}
|
||||
{#if collection.is_archived}
|
||||
<button class="btn btn-neutral mb-2" on:click={() => archiveCollection(false)}>
|
||||
|
@ -153,6 +162,13 @@
|
|||
><TrashCan class="w-6 h-6" />Delete</button
|
||||
>
|
||||
{/if}
|
||||
{#if type == 'viewonly'}
|
||||
<button
|
||||
class="btn btn-neutral mb-2"
|
||||
on:click={() => goto(`/collections/${collection.id}`)}
|
||||
><Launch class="w-5 h-5 mr-1" />Open Details</button
|
||||
>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { getFlag } from '$lib';
|
||||
import { continentCodeToString, getFlag } from '$lib';
|
||||
import type { Country } from '$lib/types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
@ -13,7 +13,7 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl overflow-hidden text-base-content"
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
|
||||
>
|
||||
<figure>
|
||||
<!-- svelte-ignore a11y-img-redundant-alt -->
|
||||
|
@ -21,6 +21,7 @@
|
|||
</figure>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title overflow-ellipsis">{country.name}</h2>
|
||||
<div class="badge badge-primary">{continentCodeToString(country.continent)}</div>
|
||||
<div class="card-actions justify-end">
|
||||
<!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> -->
|
||||
<button class="btn btn-primary" on:click={nav}>Open</button>
|
||||
|
|
|
@ -11,7 +11,9 @@
|
|||
import Flower from '~icons/mdi/flower';
|
||||
import Water from '~icons/mdi/water';
|
||||
import AboutModal from './AboutModal.svelte';
|
||||
import AccountMultiple from '~icons/mdi/account-multiple';
|
||||
import Avatar from './Avatar.svelte';
|
||||
import PaletteOutline from '~icons/mdi/palette-outline';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let query: string = '';
|
||||
|
@ -81,6 +83,9 @@
|
|||
<li>
|
||||
<button on:click={() => goto('/map')}>Map</button>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => goto('/users')}>Users</button>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#if !data.user}
|
||||
|
@ -133,6 +138,11 @@
|
|||
<li>
|
||||
<button class="btn btn-neutral" on:click={() => goto('/map')}>Map</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="btn btn-neutral" on:click={() => goto('/users')}
|
||||
><AccountMultiple /></button
|
||||
>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#if !data.user}
|
||||
|
@ -184,6 +194,10 @@
|
|||
on:click={() => (window.location.href = 'https://docs.adventurelog.app/')}
|
||||
>Documentation</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm mt-2"
|
||||
on:click={() => (window.location.href = 'https://discord.gg/wRbQ9Egr8C')}>Discord</button
|
||||
>
|
||||
<p class="font-bold m-4 text-lg">Theme Selection</p>
|
||||
<form method="POST" use:enhance={submitUpdateTheme}>
|
||||
<li>
|
||||
|
@ -202,7 +216,12 @@
|
|||
</li>
|
||||
<li>
|
||||
<button formaction="/?/setTheme&theme=forest">Forest<Forest class="w-6 h-6" /></button>
|
||||
<button formaction="/?/setTheme&theme=garden">Garden<Flower class="w-6 h-6" /></button>
|
||||
<button formaction="/?/setTheme&theme=aestheticLight"
|
||||
>Aesthetic Light<PaletteOutline class="w-6 h-6" /></button
|
||||
>
|
||||
<button formaction="/?/setTheme&theme=aestheticDark"
|
||||
>Aesthetic Dark<PaletteOutline class="w-6 h-6" /></button
|
||||
>
|
||||
<button formaction="/?/setTheme&theme=aqua">Aqua<Water class="w-6 h-6" /></button>
|
||||
</li>
|
||||
</form>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import type { Note, User } from '$lib/types';
|
||||
import type { Collection, Note, User } from '$lib/types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
@ -11,6 +11,7 @@
|
|||
|
||||
export let note: Note;
|
||||
export let user: User | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
|
||||
function editNote() {
|
||||
dispatch('edit', note);
|
||||
|
@ -30,7 +31,7 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl overflow-hidden text-base-content"
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md overflow-hidden bg-neutral text-neutral-content shadow-xl"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between">
|
||||
|
@ -52,10 +53,10 @@
|
|||
<!-- <button class="btn btn-neutral mb-2" on:click={() => goto(`/notes/${note.id}`)}
|
||||
><Launch class="w-6 h-6" />Open Details</button
|
||||
> -->
|
||||
<button class="btn btn-neutral mb-2" on:click={editNote}>
|
||||
<button class="btn btn-neutral-200 mb-2" on:click={editNote}>
|
||||
<Launch class="w-6 h-6" />Open
|
||||
</button>
|
||||
{#if note.user_id == user?.pk}
|
||||
{#if note.user_id == user?.pk || (collection && user && collection.shared_with.includes(user.uuid))}
|
||||
<button
|
||||
id="delete_adventure"
|
||||
data-umami-event="Delete Adventure"
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
import { onMount } from 'svelte';
|
||||
import ShareModal from './ShareModal.svelte';
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
export let note: Note | null = null;
|
||||
|
@ -113,7 +114,7 @@
|
|||
<p class="font-semibold text-md mb-2">Editing note {initialName}</p>
|
||||
{/if}
|
||||
|
||||
{#if (note && user?.pk == note?.user_id) || !note}
|
||||
{#if (note && user?.pk == note?.user_id) || (collection && user && collection.shared_with.includes(user.uuid)) || !note}
|
||||
<form on:submit|preventDefault>
|
||||
<div class="form-control mb-2">
|
||||
<label for="name">Name</label>
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl overflow-hidden text-base-content"
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
|
||||
>
|
||||
<div class="card-body">
|
||||
{#if region.name_en && region.name !== region.name_en}
|
||||
|
|
118
frontend/src/lib/components/ShareModal.svelte
Normal file
118
frontend/src/lib/components/ShareModal.svelte
Normal file
|
@ -0,0 +1,118 @@
|
|||
<script lang="ts">
|
||||
import type { Collection, User } from '$lib/types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
import { onMount } from 'svelte';
|
||||
import UserCard from './UserCard.svelte';
|
||||
import { addToast } from '$lib/toasts';
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
export let collection: Collection;
|
||||
|
||||
let allUsers: User[] = [];
|
||||
|
||||
let sharedWithUsers: User[] = [];
|
||||
let notSharedWithUsers: User[] = [];
|
||||
|
||||
async function share(user: User) {
|
||||
let res = await fetch(`/api/collections/${collection.id}/share/${user.uuid}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
sharedWithUsers = sharedWithUsers.concat(user);
|
||||
collection.shared_with.push(user.uuid);
|
||||
notSharedWithUsers = notSharedWithUsers.filter((u) => u.uuid !== user.uuid);
|
||||
addToast('success', `Shared ${collection.name} with ${user.first_name} ${user.last_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function unshare(user: User) {
|
||||
let res = await fetch(`/api/collections/${collection.id}/unshare/${user.uuid}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
notSharedWithUsers = notSharedWithUsers.concat(user);
|
||||
collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid);
|
||||
sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid);
|
||||
addToast('success', `Unshared ${collection.name} with ${user.first_name} ${user.last_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
modal.showModal();
|
||||
}
|
||||
let res = await fetch(`/auth/users/`);
|
||||
if (res.ok) {
|
||||
let data = await res.json();
|
||||
allUsers = data;
|
||||
sharedWithUsers = allUsers.filter((user) => collection.shared_with.includes(user.uuid));
|
||||
notSharedWithUsers = allUsers.filter((user) => !collection.shared_with.includes(user.uuid));
|
||||
console.log(sharedWithUsers);
|
||||
console.log(notSharedWithUsers);
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
dispatch('close');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog id="my_modal_1" class="modal">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<div class="modal-box w-11/12 max-w-5xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h3 class="font-bold text-lg">Share {collection.name}</h3>
|
||||
<p class="py-1">Share this collection with other users.</p>
|
||||
<div class="divider"></div>
|
||||
<h3 class="font-bold text-md">Shared With</h3>
|
||||
<ul>
|
||||
{#each sharedWithUsers as user}
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
<UserCard
|
||||
{user}
|
||||
shared_with={collection.shared_with}
|
||||
sharing={true}
|
||||
on:share={(event) => share(event.detail)}
|
||||
on:unshare={(event) => unshare(event.detail)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if sharedWithUsers.length === 0}
|
||||
<p class="text-neutral-content">No users shared with</p>
|
||||
{/if}
|
||||
</ul>
|
||||
<div class="divider"></div>
|
||||
<h3 class="font-bold text-md">Not Shared With</h3>
|
||||
<ul>
|
||||
{#each notSharedWithUsers as user}
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
<UserCard
|
||||
{user}
|
||||
shared_with={collection.shared_with}
|
||||
sharing={true}
|
||||
on:share={(event) => share(event.detail)}
|
||||
on:unshare={(event) => unshare(event.detail)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if notSharedWithUsers.length === 0}
|
||||
<p class="text-neutral-content">No users not shared with</p>
|
||||
{/if}
|
||||
</ul>
|
||||
<button class="btn btn-primary mt-4" on:click={close}>Close</button>
|
||||
</div>
|
||||
</dialog>
|
|
@ -1,22 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import Launch from '~icons/mdi/launch';
|
||||
import TrashCanOutline from '~icons/mdi/trash-can-outline';
|
||||
|
||||
import FileDocumentEdit from '~icons/mdi/file-document-edit';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Collection, Transportation, User } from '$lib/types';
|
||||
import { addToast } from '$lib/toasts';
|
||||
|
||||
import Plus from '~icons/mdi/plus';
|
||||
import ArrowDownThick from '~icons/mdi/arrow-down-thick';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let transportation: Transportation;
|
||||
export let user: User | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
|
||||
function editTransportation() {
|
||||
dispatch('edit', transportation);
|
||||
|
@ -40,7 +35,7 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl text-base-content"
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title overflow-ellipsis">{transportation.name}</h2>
|
||||
|
@ -64,7 +59,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
{#if user?.pk === transportation.user_id}
|
||||
{#if transportation.user_id == user?.pk || (collection && user && collection.shared_with.includes(user.uuid))}
|
||||
<div class="card-actions justify-end">
|
||||
<button on:click={deleteTransportation} class="btn btn-secondary"
|
||||
><TrashCanOutline class="w-5 h-5 mr-1" /></button
|
||||
|
|
51
frontend/src/lib/components/UserCard.svelte
Normal file
51
frontend/src/lib/components/UserCard.svelte
Normal file
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { continentCodeToString, getFlag } from '$lib';
|
||||
import type { User } from '$lib/types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
import Calendar from '~icons/mdi/calendar';
|
||||
|
||||
export let sharing: boolean = false;
|
||||
export let shared_with: string[] | undefined = undefined;
|
||||
|
||||
export let user: User;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div>
|
||||
{#if user.profile_pic}
|
||||
<div class="avatar">
|
||||
<div class="w-24 rounded-full">
|
||||
<img src={user.profile_pic} alt={user.username} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<h2 class="card-title overflow-ellipsis">{user.first_name} {user.last_name}</h2>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-content">{user.username}</p>
|
||||
{#if user.is_staff}
|
||||
<div class="badge badge-primary">Admin</div>
|
||||
{/if}
|
||||
<!-- member since -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 mr-1" />
|
||||
<p class="text-sm text-neutral-content">
|
||||
{user.date_joined ? 'Joined ' + new Date(user.date_joined).toLocaleDateString() : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions justify-end">
|
||||
{#if !sharing}
|
||||
<button class="btn btn-primary" on:click={() => goto(`/user/${user.uuid}`)}>View</button>
|
||||
{:else if shared_with && !shared_with.includes(user.uuid)}
|
||||
<button class="btn btn-primary" on:click={() => dispatch('share', user)}>Share</button>
|
||||
{:else}
|
||||
<button class="btn btn-primary" on:click={() => dispatch('unshare', user)}>Unshare</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,5 +1,4 @@
|
|||
export let appVersion = 'Web v0.6.0';
|
||||
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.5.2';
|
||||
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.6.0';
|
||||
export let appTitle = 'AdventureLog';
|
||||
export let copyrightYear = '2024';
|
||||
// config for the frontend
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import inspirationalQuotes from './json/quotes.json';
|
||||
import type { Adventure, Checklist, Collection, Note, Transportation } from './types';
|
||||
import type { Adventure, Checklist, Collection, Note, Transportation, User } from './types';
|
||||
|
||||
export function getRandomQuote() {
|
||||
const quotes = inspirationalQuotes.quotes;
|
||||
|
@ -177,3 +177,24 @@ export function groupChecklistsByDate(
|
|||
|
||||
return groupedChecklists;
|
||||
}
|
||||
|
||||
export function continentCodeToString(code: string) {
|
||||
switch (code) {
|
||||
case 'AF':
|
||||
return 'Africa';
|
||||
case 'AN':
|
||||
return 'Antarctica';
|
||||
case 'AS':
|
||||
return 'Asia';
|
||||
case 'EU':
|
||||
return 'Europe';
|
||||
case 'NA':
|
||||
return 'North America';
|
||||
case 'OC':
|
||||
return 'Oceania';
|
||||
case 'SA':
|
||||
return 'South America';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ export type User = {
|
|||
date_joined: string | null;
|
||||
is_staff: boolean;
|
||||
profile_pic: string | null;
|
||||
uuid: string;
|
||||
public_profile: boolean;
|
||||
};
|
||||
|
||||
export type Adventure = {
|
||||
|
@ -78,6 +80,7 @@ export type Collection = {
|
|||
notes?: Note[];
|
||||
checklists?: Checklist[];
|
||||
is_archived?: boolean;
|
||||
shared_with: string[];
|
||||
};
|
||||
|
||||
export type OpenStreetMapPlace = {
|
||||
|
|
|
@ -4,21 +4,20 @@
|
|||
import Lost from '$lib/assets/undraw_lost.svg';
|
||||
</script>
|
||||
|
||||
<h1>{$page.status}: {$page.error?.message}</h1>
|
||||
|
||||
{#if $page.status === 404}
|
||||
<div
|
||||
class="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div class="mx-auto max-w-md text-center">
|
||||
<img src={Lost} alt="Lost in the forest" />
|
||||
<h1 class="mt-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
<h1 class="text-center text-5xl font-extrabold mt-2">
|
||||
{$page.status}: {$page.error?.message}
|
||||
</h1>
|
||||
<h1 class="mt-4 text-xl font-bold tracking-tight text-foreground">
|
||||
Oops, looks like you've wandered off the beaten path.
|
||||
</h1>
|
||||
<p class="mt-4 text-muted-foreground">
|
||||
We couldn't find the page you were looking for. Don't worry, we can help you find your way
|
||||
back.ry, we can
|
||||
</p>
|
||||
|
||||
<p class="mt-4 text-muted-foreground">We couldn't find the page you were looking for.</p>
|
||||
<div class="mt-6 flex flex-col items-center gap-4 sm:flex-row">
|
||||
<button class="btn btn-neutral" on:click={() => goto('/')}>Go to Homepage</button>
|
||||
</div>
|
||||
|
|
|
@ -9,9 +9,18 @@ export const actions: Actions = {
|
|||
// change the theme only if it is one of the allowed themes
|
||||
if (
|
||||
theme &&
|
||||
['light', 'dark', 'night', 'retro', 'forest', 'aqua', 'forest', 'garden', 'emerald'].includes(
|
||||
theme
|
||||
)
|
||||
[
|
||||
'light',
|
||||
'dark',
|
||||
'night',
|
||||
'retro',
|
||||
'forest',
|
||||
'aqua',
|
||||
'forest',
|
||||
'aestheticLight',
|
||||
'aestheticDark',
|
||||
'emerald'
|
||||
].includes(theme)
|
||||
) {
|
||||
cookies.set('colortheme', theme, {
|
||||
path: '/',
|
||||
|
|
|
@ -99,72 +99,5 @@ export const actions: Actions = {
|
|||
status: 204
|
||||
};
|
||||
}
|
||||
},
|
||||
addToCollection: async (event) => {
|
||||
const id = event.params as { id: string };
|
||||
const adventureId = id.id;
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const trip_id = formData.get('collection_id');
|
||||
|
||||
if (!trip_id) {
|
||||
return {
|
||||
status: 400,
|
||||
error: { message: 'Missing collection id' }
|
||||
};
|
||||
}
|
||||
|
||||
if (!event.locals.user) {
|
||||
const refresh = event.cookies.get('refresh');
|
||||
let auth = event.cookies.get('auth');
|
||||
if (!refresh) {
|
||||
return {
|
||||
status: 401,
|
||||
body: { message: 'Unauthorized' }
|
||||
};
|
||||
}
|
||||
let res = await tryRefreshToken(refresh);
|
||||
if (res) {
|
||||
auth = res;
|
||||
event.cookies.set('auth', auth, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
expires: new Date(Date.now() + 60 * 60 * 1000), // 60 minutes
|
||||
path: '/'
|
||||
});
|
||||
} else {
|
||||
return {
|
||||
status: 401,
|
||||
body: { message: 'Unauthorized' }
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!adventureId) {
|
||||
return {
|
||||
status: 400,
|
||||
error: new Error('Bad request')
|
||||
};
|
||||
}
|
||||
|
||||
let res = await fetch(`${serverEndpoint}/api/adventures/${event.params.id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Cookie: `${event.cookies.get('auth')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ collection: trip_id })
|
||||
});
|
||||
let res2 = await res.json();
|
||||
console.log(res2);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: res.status,
|
||||
error: new Error('Failed to delete adventure')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 204
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -96,11 +96,13 @@
|
|||
{/if}
|
||||
|
||||
{#if adventure}
|
||||
{#if data.user && data.user.pk == adventure.user_id}
|
||||
<div class="fixed bottom-4 right-4 z-[999]">
|
||||
<button class="btn m-1 size-16 btn-primary" on:click={() => (isEditModalOpen = true)}
|
||||
><ClipboardList class="w-8 h-8" /></button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col min-h-dvh">
|
||||
<main class="flex-1">
|
||||
<div class="max-w-5xl mx-auto p-4 md:p-6 lg:p-8">
|
||||
|
|
83
frontend/src/routes/auth/[...path]/+server.ts
Normal file
83
frontend/src/routes/auth/[...path]/+server.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function GET({ url, params, request, fetch, cookies }) {
|
||||
// add the param format = json to the url or add additional if anothre param is already present
|
||||
if (url.search) {
|
||||
url.search = url.search + '&format=json';
|
||||
} else {
|
||||
url.search = '?format=json';
|
||||
}
|
||||
return handleRequest(url, params, request, fetch, cookies);
|
||||
}
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function POST({ url, params, request, fetch, cookies }) {
|
||||
return handleRequest(url, params, request, fetch, cookies, true);
|
||||
}
|
||||
|
||||
export async function PATCH({ url, params, request, fetch, cookies }) {
|
||||
return handleRequest(url, params, request, fetch, cookies, true);
|
||||
}
|
||||
|
||||
export async function PUT({ url, params, request, fetch, cookies }) {
|
||||
return handleRequest(url, params, request, fetch, cookies, true);
|
||||
}
|
||||
|
||||
export async function DELETE({ url, params, request, fetch, cookies }) {
|
||||
return handleRequest(url, params, request, fetch, cookies, true);
|
||||
}
|
||||
|
||||
// Implement other HTTP methods as needed (PUT, DELETE, etc.)
|
||||
|
||||
async function handleRequest(
|
||||
url: any,
|
||||
params: any,
|
||||
request: any,
|
||||
fetch: any,
|
||||
cookies: any,
|
||||
requreTrailingSlash: boolean | undefined = false
|
||||
) {
|
||||
const path = params.path;
|
||||
let targetUrl = `${endpoint}/auth/${path}${url.search}`;
|
||||
|
||||
if (requreTrailingSlash && !targetUrl.endsWith('/')) {
|
||||
targetUrl += '/';
|
||||
}
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
|
||||
const authCookie = cookies.get('auth');
|
||||
|
||||
if (authCookie) {
|
||||
headers.set('Cookie', `${authCookie}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers: headers,
|
||||
body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
// For 204 No Content, return a response with no body
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: response.headers
|
||||
});
|
||||
}
|
||||
|
||||
const responseData = await response.text();
|
||||
|
||||
return new Response(responseData, {
|
||||
status: response.status,
|
||||
headers: response.headers
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error forwarding request:', error);
|
||||
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
|
@ -88,16 +88,18 @@
|
|||
return;
|
||||
} else {
|
||||
let adventure = event.detail;
|
||||
let formData = new FormData();
|
||||
formData.append('collection_id', collection.id.toString());
|
||||
|
||||
let res = await fetch(`/adventures/${adventure.id}?/addToCollection`, {
|
||||
method: 'POST',
|
||||
body: formData // Remove the Content-Type header
|
||||
let res = await fetch(`/api/adventures/${adventure.id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ collection: collection.id.toString() })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
console.log('Adventure added to collection');
|
||||
adventure = await res.json();
|
||||
adventures = [...adventures, adventure];
|
||||
} else {
|
||||
console.log('Error adding adventure to collection');
|
||||
|
@ -404,6 +406,7 @@
|
|||
type={adventure.type}
|
||||
{adventure}
|
||||
on:typeChange={changeType}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
@ -423,6 +426,7 @@
|
|||
transportationToEdit = event.detail;
|
||||
isTransportationEditModalOpen = true;
|
||||
}}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
@ -442,6 +446,7 @@
|
|||
on:delete={(event) => {
|
||||
notes = notes.filter((n) => n.id != event.detail);
|
||||
}}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
@ -461,6 +466,7 @@
|
|||
checklistToEdit = event.detail;
|
||||
isShowingChecklistModal = true;
|
||||
}}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
@ -45,6 +45,7 @@ export const actions: Actions = {
|
|||
let first_name = formData.get('first_name') as string | null | undefined;
|
||||
let last_name = formData.get('last_name') as string | null | undefined;
|
||||
let profile_pic = formData.get('profile_pic') as File | null | undefined;
|
||||
let public_profile = formData.get('public_profile') as string | null | undefined | boolean;
|
||||
|
||||
const resCurrent = await fetch(`${endpoint}/auth/user/`, {
|
||||
headers: {
|
||||
|
@ -56,6 +57,13 @@ export const actions: Actions = {
|
|||
return fail(resCurrent.status, await resCurrent.json());
|
||||
}
|
||||
|
||||
if (public_profile === 'on') {
|
||||
public_profile = true;
|
||||
} else {
|
||||
public_profile = false;
|
||||
}
|
||||
console.log(public_profile);
|
||||
|
||||
let currentUser = (await resCurrent.json()) as User;
|
||||
|
||||
if (username === currentUser.username || !username) {
|
||||
|
@ -84,6 +92,7 @@ export const actions: Actions = {
|
|||
if (profile_pic) {
|
||||
formDataToSend.append('profile_pic', profile_pic);
|
||||
}
|
||||
formDataToSend.append('public_profile', public_profile.toString());
|
||||
|
||||
let res = await fetch(`${endpoint}/auth/user/`, {
|
||||
method: 'PATCH',
|
||||
|
|
|
@ -111,6 +111,24 @@
|
|||
id="profile_pic"
|
||||
class="file-input file-input-bordered w-full max-w-xs mb-2"
|
||||
/><br />
|
||||
<div class="form-control">
|
||||
<div
|
||||
class="tooltip tooltip-info"
|
||||
data-tip="With a public profile, users can share collections with you and view your profile on the users page."
|
||||
>
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">Public Profile</span>
|
||||
|
||||
<input
|
||||
id="public_profile"
|
||||
name="public_profile"
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
checked={user.public_profile}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="py-2 mt-2 px-4 btn btn-primary">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
26
frontend/src/routes/shared/+page.server.ts
Normal file
26
frontend/src/routes/shared/+page.server.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
export const load = (async (event) => {
|
||||
if (!event.locals.user) {
|
||||
return redirect(302, '/login');
|
||||
} else {
|
||||
let res = await fetch(`${serverEndpoint}/api/collections/shared/`, {
|
||||
headers: {
|
||||
Cookie: `${event.cookies.get('auth')}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
return redirect(302, '/login');
|
||||
} else {
|
||||
return {
|
||||
props: {
|
||||
collections: await res.json()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}) satisfies PageServerLoad;
|
27
frontend/src/routes/shared/+page.svelte
Normal file
27
frontend/src/routes/shared/+page.svelte
Normal file
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import CollectionCard from '$lib/components/CollectionCard.svelte';
|
||||
import type { Collection } from '$lib/types';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
console.log(data);
|
||||
let collections: Collection[] = data.props.collections;
|
||||
</script>
|
||||
|
||||
{#if collections.length > 0}
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each collections as collection}
|
||||
<CollectionCard type="viewonly" {collection} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center font-semibold text-xl mt-6">
|
||||
No collections found that are shared with you.
|
||||
{#if data.user && !data.user?.public_profile}
|
||||
<p>In order to allow users to share with you, you need your profile set to public.</p>
|
||||
<button class="btn btn-neutral mt-4" on:click={() => goto('/settings')}>Go to Settings</button
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
29
frontend/src/routes/user/[uuid]/+page.server.ts
Normal file
29
frontend/src/routes/user/[uuid]/+page.server.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
export const load = (async (event) => {
|
||||
if (!event.cookies.get('auth')) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
const uuid = event.params.uuid;
|
||||
if (!uuid) {
|
||||
return redirect(302, '/users');
|
||||
}
|
||||
let res = await fetch(`${serverEndpoint}/auth/user/${uuid}/`, {
|
||||
headers: {
|
||||
Cookie: `${event.cookies.get('auth')}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
return redirect(302, '/users');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
return {
|
||||
props: {
|
||||
user: data
|
||||
}
|
||||
};
|
||||
}
|
||||
}) satisfies PageServerLoad;
|
35
frontend/src/routes/user/[uuid]/+page.svelte
Normal file
35
frontend/src/routes/user/[uuid]/+page.svelte
Normal file
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
const user = data.props.user;
|
||||
console.log(user);
|
||||
</script>
|
||||
|
||||
{#if user.profile_pic}
|
||||
<div class="avatar flex items-center justify-center mt-4">
|
||||
<div class="w-48 rounded-md">
|
||||
<img src={user.profile_pic} alt={user.username} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h1 class="text-center font-semibold text-4xl mt-4">{user.first_name} {user.last_name}</h1>
|
||||
<h2 class="text-center font-semibold text-2xl">{user.username}</h2>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
{#if user.is_staff}
|
||||
<div class="badge badge-primary">Admin</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<p class="text-sm text-neutral-content">
|
||||
{user.date_joined ? 'Joined ' + new Date(user.date_joined).toLocaleDateString() : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<svelte:head>
|
||||
<title>{user.username} | AdventureLog</title>
|
||||
<meta name="description" content="View your adventure collections." />
|
||||
</svelte:head>
|
26
frontend/src/routes/users/+page.server.ts
Normal file
26
frontend/src/routes/users/+page.server.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
export const load = (async (event) => {
|
||||
if (!event.cookies.get('auth')) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
const res = await fetch(`${serverEndpoint}/auth/users/`, {
|
||||
headers: {
|
||||
Cookie: `${event.cookies.get('auth')}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
return redirect(302, '/login');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
return {
|
||||
props: {
|
||||
users: data
|
||||
}
|
||||
};
|
||||
}
|
||||
}) satisfies PageServerLoad;
|
25
frontend/src/routes/users/+page.svelte
Normal file
25
frontend/src/routes/users/+page.svelte
Normal file
|
@ -0,0 +1,25 @@
|
|||
<script lang="ts">
|
||||
import UserCard from '$lib/components/UserCard.svelte';
|
||||
import type { User } from '$lib/types';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
let users: User[] = data.props.users;
|
||||
console.log(users);
|
||||
</script>
|
||||
|
||||
<h1 class="text-center font-bold text-4xl mb-4">AdventureLog Users</h1>
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each users as user (user.uuid)}
|
||||
<UserCard {user} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if users.length === 0}
|
||||
<p class="text-center">No users found with public profiles.</p>
|
||||
{/if}
|
||||
|
||||
<svelte:head>
|
||||
<title>Users</title>
|
||||
<meta name="description" content="View your adventure collections." />
|
||||
</svelte:head>
|
|
@ -3,16 +3,44 @@
|
|||
import type { Country } from '$lib/types';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let searchQuery: string = '';
|
||||
|
||||
let filteredCountries: Country[] = [];
|
||||
|
||||
export let data: PageData;
|
||||
console.log(data);
|
||||
|
||||
const countries: Country[] = data.props?.countries || [];
|
||||
|
||||
$: {
|
||||
// if query is empty, show all countries
|
||||
if (searchQuery === '') {
|
||||
filteredCountries = countries;
|
||||
} else {
|
||||
// otherwise, filter countries by name
|
||||
filteredCountries = countries.filter((country) =>
|
||||
country.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1 class="text-center font-bold text-4xl mb-4">Country List</h1>
|
||||
<h1 class="text-center font-bold text-4xl">Country List</h1>
|
||||
<!-- result count -->
|
||||
<p class="text-center mb-4">
|
||||
{filteredCountries.length} countries found
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
|
||||
{#each countries as country}
|
||||
{#each filteredCountries as country}
|
||||
<CountryCard {country} />
|
||||
<!-- <p>Name: {item.name}, Continent: {item.continent}</p> -->
|
||||
{/each}
|
||||
|
|
|
@ -15,7 +15,52 @@ export default {
|
|||
'aqua',
|
||||
'emerald',
|
||||
{
|
||||
garden: {
|
||||
aestheticDark: {
|
||||
primary: '#3e5747',
|
||||
'primary-focus': '#2f4236',
|
||||
'primary-content': '#e9e7e7',
|
||||
|
||||
secondary: '#547b82',
|
||||
'secondary-focus': '#3d5960',
|
||||
'secondary-content': '#c1dfe5',
|
||||
|
||||
accent: '#8b6161',
|
||||
'accent-focus': '#6e4545',
|
||||
'accent-content': '#f2eaea',
|
||||
|
||||
neutral: '#2b2a2a',
|
||||
'neutral-focus': '#272525',
|
||||
'neutral-content': '#e9e7e7',
|
||||
|
||||
'base-100': '#121212', // Dark background
|
||||
'base-200': '#1d1d1d',
|
||||
'base-300': '#292929',
|
||||
'base-content': '#e9e7e7', // Light content on dark background
|
||||
|
||||
// set bg-primary-content
|
||||
'bg-base': '#121212',
|
||||
'bg-base-content': '#e9e7e7',
|
||||
|
||||
info: '#3b7ecb',
|
||||
success: '#007766',
|
||||
warning: '#d4c111',
|
||||
error: '#e64a19',
|
||||
|
||||
'--rounded-box': '1rem',
|
||||
'--rounded-btn': '.5rem',
|
||||
'--rounded-badge': '1.9rem',
|
||||
|
||||
'--animation-btn': '.25s',
|
||||
'--animation-input': '.2s',
|
||||
|
||||
'--btn-text-case': 'uppercase',
|
||||
'--navbar-padding': '.5rem',
|
||||
'--border-btn': '1px',
|
||||
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
},
|
||||
aestheticLight: {
|
||||
primary: '#5a7c65',
|
||||
'primary-focus': '#48604f',
|
||||
'primary-content': '#dcd5d5',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue