From 4a7f72077342fd5a019927c71e128958cc9dcf4f Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Thu, 14 Nov 2024 09:37:35 -0500 Subject: [PATCH] Initial framework for custom categories --- backend/server/adventures/admin.py | 27 +++++++-- .../0011_category_adventure_category.py | 34 +++++++++++ .../0012_migrate_types_to_categories.py | 59 +++++++++++++++++++ ...adventure_type_alter_adventure_category.py | 23 ++++++++ backend/server/adventures/models.py | 31 +++++++++- backend/server/adventures/serializers.py | 17 +++++- backend/server/adventures/urls.py | 3 +- backend/server/adventures/views.py | 22 ++++++- 8 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 backend/server/adventures/migrations/0011_category_adventure_category.py create mode 100644 backend/server/adventures/migrations/0012_migrate_types_to_categories.py create mode 100644 backend/server/adventures/migrations/0013_remove_adventure_type_alter_adventure_category.py diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index b7df19d..6160f60 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,15 +1,30 @@ import os from django.contrib import admin from django.utils.html import mark_safe -from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit +from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category from worldtravel.models import Country, Region, VisitedRegion class AdventureAdmin(admin.ModelAdmin): - list_display = ('name', 'type', 'user_id', 'is_public') - list_filter = ('type', 'user_id', 'is_public') + list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public') + list_filter = ( 'user_id', 'is_public') search_fields = ('name',) + def get_category(self, obj): + if obj.category and obj.category.display_name and obj.category.icon: + return obj.category.display_name + ' ' + obj.category.icon + elif obj.category and obj.category.name: + return obj.category.name + else: + return 'No Category' + + get_category.short_description = 'Category' + + def get_visit_count(self, obj): + return obj.visits.count() + + get_visit_count.short_description = 'Visit Count' + class CountryAdmin(admin.ModelAdmin): list_display = ('name', 'country_code', 'number_of_regions') @@ -80,7 +95,10 @@ class VisitAdmin(admin.ModelAdmin): return image_display.short_description = 'Image Preview' - + +class CategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'user_id', 'display_name', 'icon') + search_fields = ('name', 'display_name') class CollectionAdmin(admin.ModelAdmin): def adventure_count(self, obj): @@ -105,6 +123,7 @@ admin.site.register(Note) admin.site.register(Checklist) admin.site.register(ChecklistItem) admin.site.register(AdventureImage, AdventureImageAdmin) +admin.site.register(Category, CategoryAdmin) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/adventures/migrations/0011_category_adventure_category.py b/backend/server/adventures/migrations/0011_category_adventure_category.py new file mode 100644 index 0000000..84ae013 --- /dev/null +++ b/backend/server/adventures/migrations/0011_category_adventure_category.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.8 on 2024-11-14 04:30 + +from django.conf import settings +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0010_collection_link'), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=200)), + ('display_name', models.CharField(max_length=200)), + ('icon', models.CharField(default='🌎', max_length=200)), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'Categories', + }, + ), + migrations.AddField( + model_name='adventure', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.category'), + ), + ] diff --git a/backend/server/adventures/migrations/0012_migrate_types_to_categories.py b/backend/server/adventures/migrations/0012_migrate_types_to_categories.py new file mode 100644 index 0000000..8aea132 --- /dev/null +++ b/backend/server/adventures/migrations/0012_migrate_types_to_categories.py @@ -0,0 +1,59 @@ +from django.db import migrations + +def migrate_categories(apps, schema_editor): + # Use the historical models + Adventure = apps.get_model('adventures', 'Adventure') + Category = apps.get_model('adventures', 'Category') + + ADVENTURE_TYPES = { + 'general': ('General', '🌍'), + 'outdoor': ('Outdoor', '🏞️'), + 'lodging': ('Lodging', '🛌'), + 'dining': ('Dining', '🍽️'), + 'activity': ('Activity', '🏄'), + 'attraction': ('Attraction', '🎢'), + 'shopping': ('Shopping', '🛍️'), + 'nightlife': ('Nightlife', '🌃'), + 'event': ('Event', '🎉'), + 'transportation': ('Transportation', '🚗'), + 'culture': ('Culture', '🎭'), + 'water_sports': ('Water Sports', '🚤'), + 'hiking': ('Hiking', '🥾'), + 'wildlife': ('Wildlife', '🦒'), + 'historical_sites': ('Historical Sites', '🏛️'), + 'music_concerts': ('Music & Concerts', '🎶'), + 'fitness': ('Fitness', '🏋️'), + 'art_museums': ('Art & Museums', '🎨'), + 'festivals': ('Festivals', '🎪'), + 'spiritual_journeys': ('Spiritual Journeys', '🧘‍♀️'), + 'volunteer_work': ('Volunteer Work', '🤝'), + 'other': ('Other', '❓'), + } + + adventures = Adventure.objects.all() + for adventure in adventures: + # Access the old 'type' field using __dict__ because it's not in the model anymore + old_type = adventure.__dict__.get('type') + if old_type in ADVENTURE_TYPES: + category, created = Category.objects.get_or_create( + name=old_type, + user_id=adventure.user_id, + defaults={ + 'display_name': ADVENTURE_TYPES[old_type][0], + 'icon': ADVENTURE_TYPES[old_type][1], + } + ) + adventure.category = category + adventure.save() + else: + print(f"Unknown type: {old_type}") + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0011_category_adventure_category'), + ] + + operations = [ + migrations.RunPython(migrate_categories), + ] \ No newline at end of file diff --git a/backend/server/adventures/migrations/0013_remove_adventure_type_alter_adventure_category.py b/backend/server/adventures/migrations/0013_remove_adventure_type_alter_adventure_category.py new file mode 100644 index 0000000..d52f950 --- /dev/null +++ b/backend/server/adventures/migrations/0013_remove_adventure_type_alter_adventure_category.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.8 on 2024-11-14 04:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0012_migrate_types_to_categories'), + ] + + operations = [ + migrations.RemoveField( + model_name='adventure', + name='type', + ), + migrations.AlterField( + model_name='adventure', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='adventures.category'), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index ea5a214..8bf0fa9 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -1,3 +1,4 @@ +from collections.abc import Collection import uuid from django.db import models @@ -68,7 +69,8 @@ class Adventure(models.Model): id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) user_id = models.ForeignKey( User, on_delete=models.CASCADE, default=default_user_id) - type = models.CharField(max_length=100, choices=ADVENTURE_TYPES, default='general') + + category = models.ForeignKey('Category', on_delete=models.SET_NULL, blank=True, null=True) name = models.CharField(max_length=200) location = models.CharField(max_length=200, blank=True, null=True) activity_types = ArrayField(models.CharField( @@ -88,6 +90,7 @@ class Adventure(models.Model): # image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/') # date = models.DateField(blank=True, null=True) # end_date = models.DateField(blank=True, null=True) + # type = models.CharField(max_length=100, choices=ADVENTURE_TYPES, default='general') def clean(self): if self.collection: @@ -95,6 +98,9 @@ class Adventure(models.Model): raise ValidationError('Adventures associated with a public collection must be public. Collection: ' + self.trip.name + ' Adventure: ' + self.name) if self.user_id != self.collection.user_id: raise ValidationError('Adventures must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Adventure owner: ' + self.user_id.username) + if self.category: + if self.user_id != self.category.user_id: + raise ValidationError('Adventures must be associated with categories owned by the same user. Category owner: ' + self.category.user_id.username + ' Adventure owner: ' + self.user_id.username) def __str__(self): return self.name @@ -234,4 +240,25 @@ class AdventureImage(models.Model): adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE) def __str__(self): - return self.image.url \ No newline at end of file + return self.image.url + +class Category(models.Model): + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + user_id = models.ForeignKey( + User, on_delete=models.CASCADE, default=default_user_id) + name = models.CharField(max_length=200) + display_name = models.CharField(max_length=200) + icon = models.CharField(max_length=200, default='🌎') + + class Meta: + verbose_name_plural = 'Categories' + unique_together = ['name', 'user_id'] + + def clean(self) -> None: + self.name = self.name.lower().strip() + + return super().clean() + + + def __str__(self): + return self.name \ No newline at end of file diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index e3ac27a..ce54389 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,6 +1,6 @@ from django.utils import timezone import os -from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit +from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category from rest_framework import serializers class AdventureImageSerializer(serializers.ModelSerializer): @@ -19,6 +19,18 @@ class AdventureImageSerializer(serializers.ModelSerializer): representation['image'] = f"{public_url}/media/{instance.image.name}" return representation +class CategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = ['id', 'name', 'display_name', 'icon', 'user_id'] + read_only_fields = ['id', 'user_id'] + + def validate_name(self, value): + if Category.objects.filter(name=value).exists(): + raise serializers.ValidationError('Category with this name already exists.') + + return value + class VisitSerializer(serializers.ModelSerializer): class Meta: @@ -29,10 +41,11 @@ class VisitSerializer(serializers.ModelSerializer): class AdventureSerializer(serializers.ModelSerializer): images = AdventureImageSerializer(many=True, read_only=True) visits = VisitSerializer(many=True, read_only=False) + category = CategorySerializer(read_only=True) is_visited = serializers.SerializerMethodField() class Meta: model = Adventure - fields = ['id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location', 'is_public', 'collection', 'created_at', 'updated_at', 'images', 'link', 'type', 'longitude', 'latitude', 'visits', 'is_visited'] + fields = ['id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location', 'is_public', 'collection', 'created_at', 'updated_at', 'images', 'link', 'longitude', 'latitude', 'visits', 'is_visited', 'category'] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited'] def get_is_visited(self, obj): diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 3eec598..9b7566d 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import AdventureViewSet, ChecklistViewSet, CollectionViewSet, NoteViewSet, StatsViewSet, GenerateDescription, ActivityTypesView, TransportationViewSet, AdventureImageViewSet, ReverseGeocodeViewSet +from .views import AdventureViewSet, ChecklistViewSet, CollectionViewSet, NoteViewSet, StatsViewSet, GenerateDescription, ActivityTypesView, TransportationViewSet, AdventureImageViewSet, ReverseGeocodeViewSet, CategoryViewSet router = DefaultRouter() router.register(r'adventures', AdventureViewSet, basename='adventures') @@ -13,6 +13,7 @@ router.register(r'notes', NoteViewSet, basename='notes') router.register(r'checklists', ChecklistViewSet, basename='checklists') router.register(r'images', AdventureImageViewSet, basename='images') router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode') +router.register(r'categories', CategoryViewSet, basename='categories') urlpatterns = [ diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 0d8b097..03b5303 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -6,10 +6,10 @@ from rest_framework.decorators import action 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, ADVENTURE_TYPES +from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage, Category from django.core.exceptions import PermissionDenied from worldtravel.models import VisitedRegion, Region, Country -from .serializers import AdventureImageSerializer, AdventureSerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer +from .serializers import AdventureImageSerializer, AdventureSerializer, CategorySerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer from rest_framework.permissions import IsAuthenticated from django.db.models import Q from .permissions import CollectionShared, IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess @@ -610,6 +610,24 @@ class ActivityTypesView(viewsets.ViewSet): allTypes.append(x) return Response(allTypes) + +class CategoryViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get']) + def categories(self, request): + """ + Retrieve a list of distinct categories for adventures associated with the current user. + + Args: + request (HttpRequest): The HTTP request object. + + Returns: + Response: A response containing a list of distinct categories. + """ + categories = Category.objects.filter(user_id=request.user.id).distinct() + serializer = CategorySerializer(categories, many=True) + return Response(serializer.data) class TransportationViewSet(viewsets.ModelViewSet): queryset = Transportation.objects.all()