diff --git a/backend/.gitignore b/backend/.gitignore index cb5a690..80634f2 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -115,5 +115,4 @@ demo/react-spa/yarn.lock */media/* -/static/* -/staticfiles/* \ No newline at end of file +*/staticfiles/* \ No newline at end of file diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index f368e7b..af3fb9d 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,7 +1,7 @@ import os from django.contrib import admin from django.utils.html import mark_safe -from .models import Adventure +from .models import Adventure, Trip from worldtravel.models import Country, Region, VisitedRegion @@ -65,6 +65,7 @@ admin.site.register(Adventure, AdventureAdmin) admin.site.register(Country, CountryAdmin) admin.site.register(Region, RegionAdmin) admin.site.register(VisitedRegion) +admin.site.register(Trip) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/adventures/migrations/0005_remove_adventure_trip_id_trip_adventure_trip.py b/backend/server/adventures/migrations/0005_remove_adventure_trip_id_trip_adventure_trip.py new file mode 100644 index 0000000..43f80e7 --- /dev/null +++ b/backend/server/adventures/migrations/0005_remove_adventure_trip_id_trip_adventure_trip.py @@ -0,0 +1,37 @@ +# Generated by Django 5.0.6 on 2024-07-09 16:49 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0004_adventure_latitude_adventure_longitude'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='adventure', + name='trip_id', + ), + migrations.CreateModel( + name='Trip', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('type', models.CharField(max_length=100)), + ('location', models.CharField(blank=True, max_length=200, null=True)), + ('date', models.DateField(blank=True, null=True)), + ('is_public', models.BooleanField(default=False)), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='adventure', + name='trip', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.trip'), + ), + ] diff --git a/backend/server/adventures/migrations/0006_alter_adventure_type_alter_trip_type.py b/backend/server/adventures/migrations/0006_alter_adventure_type_alter_trip_type.py new file mode 100644 index 0000000..34e44f9 --- /dev/null +++ b/backend/server/adventures/migrations/0006_alter_adventure_type_alter_trip_type.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2024-07-09 16:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0005_remove_adventure_trip_id_trip_adventure_trip'), + ] + + operations = [ + migrations.AlterField( + model_name='adventure', + name='type', + field=models.CharField(choices=[('visited', 'Visited'), ('planned', 'Planned'), ('featured', 'Featured')], max_length=100), + ), + migrations.AlterField( + model_name='trip', + name='type', + field=models.CharField(choices=[('visited', 'Visited'), ('planned', 'Planned'), ('featured', 'Featured')], max_length=100), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 8e45215..af10e67 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -2,6 +2,13 @@ from django.db import models from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField +from django.forms import ValidationError + +ADVENTURE_TYPES = [ + ('visited', 'Visited'), + ('planned', 'Planned'), + ('featured', 'Featured') +] # Assuming you have a default user ID you want to use @@ -14,7 +21,7 @@ class Adventure(models.Model): id = models.AutoField(primary_key=True) user_id = models.ForeignKey( User, on_delete=models.CASCADE, default=default_user_id) - type = models.CharField(max_length=100) + type = models.CharField(max_length=100, choices=ADVENTURE_TYPES) name = models.CharField(max_length=200) location = models.CharField(max_length=200, blank=True, null=True) activity_types = ArrayField(models.CharField( @@ -24,10 +31,42 @@ class Adventure(models.Model): link = models.URLField(blank=True, null=True) image = models.ImageField(null=True, blank=True, upload_to='images/') date = models.DateField(blank=True, null=True) - trip_id = models.IntegerField(blank=True, null=True) is_public = models.BooleanField(default=False) longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + trip = models.ForeignKey('Trip', on_delete=models.CASCADE, blank=True, null=True) + + def clean(self): + if self.trip: + if self.trip.is_public and not self.is_public: + raise ValidationError('Adventures associated with a public trip must be public. Trip: ' + self.trip.name + ' Adventure: ' + self.name) + if self.user_id != self.trip.user_id: + raise ValidationError('Adventures must be associated with trips owned by the same user. Trip owner: ' + self.trip.user_id.username + ' Adventure owner: ' + self.user_id.username) + if self.type != self.trip.type: + raise ValidationError('Adventure type must match trip type. Trip type: ' + self.trip.type + ' Adventure type: ' + self.type) def __str__(self): return self.name + +class Trip(models.Model): + id = models.AutoField(primary_key=True) + user_id = models.ForeignKey( + User, on_delete=models.CASCADE, default=default_user_id) + name = models.CharField(max_length=200) + type = models.CharField(max_length=100, choices=ADVENTURE_TYPES) + location = models.CharField(max_length=200, blank=True, null=True) + date = models.DateField(blank=True, null=True) + is_public = models.BooleanField(default=False) + + # if connected adventures are private and trip is public, raise an error + def clean(self): + if self.is_public: + for adventure in self.adventure_set.all(): + if not adventure.is_public: + raise ValidationError('Public trips cannot be associated with private adventures. Trip: ' + self.name + ' Adventure: ' + adventure.name) + + + + def __str__(self): + return self.name + diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py new file mode 100644 index 0000000..f4dfbea --- /dev/null +++ b/backend/server/adventures/permissions.py @@ -0,0 +1,15 @@ +from rest_framework import permissions + +class IsOwnerOrReadOnly(permissions.BasePermission): + """ + Custom permission to only allow owners of an object to edit it. + """ + + def has_object_permission(self, request, view, obj): + # Read permissions are allowed to any request, + # so we'll always allow GET, HEAD or OPTIONS requests. + if request.method in permissions.SAFE_METHODS: + return True + + # Write permissions are only allowed to the owner of the object. + return obj.user_id == request.user \ No newline at end of file diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index c987681..69daebd 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,5 +1,5 @@ import os -from .models import Adventure +from .models import Adventure, Trip from rest_framework import serializers class AdventureSerializer(serializers.ModelSerializer): @@ -16,4 +16,12 @@ class AdventureSerializer(serializers.ModelSerializer): # remove any ' from the url public_url = public_url.replace("'", "") representation['image'] = f"{public_url}/media/{instance.image.name}" - return representation \ No newline at end of file + return representation + +class TripSerializer(serializers.ModelSerializer): + + class Meta: + model = Trip + fields = '__all__' # Serialize all fields of the Adventure model + + \ No newline at end of file diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 375e251..6c4b962 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -1,9 +1,10 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import AdventureViewSet +from .views import AdventureViewSet, TripViewSet router = DefaultRouter() router.register(r'adventures', AdventureViewSet, basename='adventures') +router.register(r'trips', TripViewSet, basename='trips') urlpatterns = [ # Include the router under the 'api/' prefix diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 057e60f..b0f1282 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -1,19 +1,17 @@ from rest_framework.decorators import action from rest_framework import viewsets from rest_framework.response import Response -from .models import Adventure -from .serializers import AdventureSerializer +from .models import Adventure, Trip +from .serializers import AdventureSerializer, TripSerializer from rest_framework.permissions import IsAuthenticated from django.db.models import Q - -# Create your views here. +from .permissions import IsOwnerOrReadOnly class AdventureViewSet(viewsets.ModelViewSet): serializer_class = AdventureSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, IsOwnerOrReadOnly] def get_queryset(self): - # Allow any user to see public adventures or their own adventures return Adventure.objects.filter( Q(is_public=True) | Q(user_id=self.request.user.id) ) @@ -21,24 +19,56 @@ class AdventureViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): serializer.save(user_id=self.request.user) - # Custom actions to return visited and planned adventures @action(detail=False, methods=['get']) def visited(self, request): visited_adventures = Adventure.objects.filter( - type='visited', user_id=request.user.id, trip_id=None) + type='visited', user_id=request.user.id, trip=None) serializer = self.get_serializer(visited_adventures, many=True) return Response(serializer.data) @action(detail=False, methods=['get']) def planned(self, request): planned_adventures = Adventure.objects.filter( - type='planned', user_id=request.user.id, trip_id=None) + type='planned', user_id=request.user.id, trip=None) serializer = self.get_serializer(planned_adventures, many=True) return Response(serializer.data) @action(detail=False, methods=['get']) def featured(self, request): featured_adventures = Adventure.objects.filter( - type='featured', is_public=True, trip_id=None) + type='featured', is_public=True, trip=None) serializer = self.get_serializer(featured_adventures, many=True) + return Response(serializer.data) + +class TripViewSet(viewsets.ModelViewSet): + serializer_class = TripSerializer + permission_classes = [IsAuthenticated, IsOwnerOrReadOnly] + + def get_queryset(self): + return Trip.objects.filter( + Q(is_public=True) | Q(user_id=self.request.user.id) + ) + + def perform_create(self, serializer): + serializer.save(user_id=self.request.user) + + @action(detail=False, methods=['get']) + def visited(self, request): + trips = Trip.objects.filter( + type='visited', user_id=request.user.id) + serializer = self.get_serializer(trips, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def planned(self, request): + trips = Trip.objects.filter( + type='planned', user_id=request.user.id) + serializer = self.get_serializer(trips, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def featured(self, request): + trips = Trip.objects.filter( + type='featured', is_public=True) + serializer = self.get_serializer(trips, many=True) return Response(serializer.data) \ No newline at end of file