diff --git a/backend/server/adventures/migrations/0014_alter_category_unique_together.py b/backend/server/adventures/migrations/0014_alter_category_unique_together.py new file mode 100644 index 0000000..79b0bdf --- /dev/null +++ b/backend/server/adventures/migrations/0014_alter_category_unique_together.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.8 on 2024-11-17 21:43 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0013_remove_adventure_type_alter_adventure_category'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='category', + unique_together={('name', 'user_id')}, + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 8bf0fa9..7225dce 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -1,4 +1,5 @@ from collections.abc import Collection +from typing import Iterable import uuid from django.db import models @@ -101,6 +102,17 @@ class Adventure(models.Model): 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 save(self, force_insert: bool = False, force_update: bool = False, using: str | None = None, update_fields: Iterable[str] | None = None) -> None: + """ + Saves the current instance. If the instance is being inserted for the first time, it will be created in the database. + If it already exists, it will be updated. + """ + if force_insert and force_update: + raise ValueError("Cannot force both insert and updating in model saving.") + if not self.category: + self.category = Category.objects.get_or_create(user_id=self.user_id, name='general', display_name='General', icon='🌎')[0] + return super().save(force_insert, force_update, using, update_fields) def __str__(self): return self.name diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 129758c..1ee8fd2 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -37,7 +37,7 @@ class CategorySerializer(CustomModelSerializer): def get_num_adventures(self, obj): return Adventure.objects.filter(category=obj, user_id=obj.user_id).count() -class VisitSerializer(CustomModelSerializer): +class VisitSerializer(serializers.ModelSerializer): class Meta: model = Visit @@ -47,13 +47,34 @@ class VisitSerializer(CustomModelSerializer): class AdventureSerializer(CustomModelSerializer): images = AdventureImageSerializer(many=True, read_only=True) visits = VisitSerializer(many=True, read_only=False) - category = CategorySerializer(read_only=True) + category = serializers.PrimaryKeyRelatedField( + queryset=Category.objects.all(), + write_only=True, + required=False + ) + category_object = CategorySerializer(source='category', 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', 'longitude', 'latitude', 'visits', 'is_visited', 'category'] + 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', 'category_object' + ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited'] + def to_representation(self, instance): + representation = super().to_representation(instance) + representation['category'] = representation.pop('category_object') + return representation + + def validate_category(self, category): + # Check that the category belongs to the same user + if category.user_id != self.context['request'].user: + raise serializers.ValidationError('Category does not belong to the user.') + return category + def get_is_visited(self, obj): current_date = timezone.now().date() for visit in obj.visits.all(): @@ -62,32 +83,24 @@ class AdventureSerializer(CustomModelSerializer): elif visit.start_date and not visit.end_date and (visit.start_date <= current_date): return True return False - - def to_representation(self, instance): - representation = super().to_representation(instance) - return representation - def create(self, validated_data): visits_data = validated_data.pop('visits', []) adventure = Adventure.objects.create(**validated_data) for visit_data in visits_data: Visit.objects.create(adventure=adventure, **visit_data) return adventure - + def update(self, instance, validated_data): visits_data = validated_data.pop('visits', []) - - # Update Adventure fields + for attr, value in validated_data.items(): setattr(instance, attr, value) instance.save() - - # Get current visits + current_visits = instance.visits.all() current_visit_ids = set(current_visits.values_list('id', flat=True)) - - # Update or create visits + updated_visit_ids = set() for visit_data in visits_data: visit_id = visit_data.get('id') @@ -98,16 +111,14 @@ class AdventureSerializer(CustomModelSerializer): visit.save() updated_visit_ids.add(visit_id) else: - # If no ID is provided or ID doesn't exist, create new visit new_visit = Visit.objects.create(adventure=instance, **visit_data) updated_visit_ids.add(new_visit.id) - - # Delete visits that are not in the updated data + visits_to_delete = current_visit_ids - updated_visit_ids instance.visits.filter(id__in=visits_to_delete).delete() - + return instance - + class TransportationSerializer(CustomModelSerializer): class Meta: diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 2380036..2405f15 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -116,8 +116,8 @@ class AdventureViewSet(viewsets.ModelViewSet): if not Category.objects.filter(user_id=request.user, name=type).exists(): return Response({"error": f"Category {type} does not exist"}, status=400) - if not types: - return Response({"error": "No valid types provided"}, status=400) + if not types: + return Response({"error": "At least one type must be provided"}, status=400) queryset = Adventure.objects.filter( category__in=Category.objects.filter(name__in=types, user_id=request.user), diff --git a/backend/server/users/migrations/0003_alter_customuser_email.py b/backend/server/users/migrations/0003_alter_customuser_email.py new file mode 100644 index 0000000..7e599bc --- /dev/null +++ b/backend/server/users/migrations/0003_alter_customuser_email.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.8 on 2024-11-18 14:51 + +from django.db import migrations, models + +def check_duplicate_email(apps, schema_editor): + # sets an email to null if there are duplicates + CustomUser = apps.get_model('users', 'CustomUser') + duplicates = CustomUser.objects.values('email').annotate(email_count=models.Count('email')).filter(email_count__gt=1) + for duplicate in duplicates: + CustomUser.objects.filter(email=duplicate['email']).update(email=None) + print(f"Duplicate email: {duplicate['email']}") + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_customuser_public_profile'), + ] + + operations = [ + migrations.RunPython(check_duplicate_email), + migrations.AlterField( + model_name='customuser', + name='email', + field=models.EmailField(max_length=254, unique=True), + ), + ] diff --git a/backend/server/users/models.py b/backend/server/users/models.py index fa28f1c..670d6e3 100644 --- a/backend/server/users/models.py +++ b/backend/server/users/models.py @@ -4,6 +4,7 @@ from django.db import models from django_resized import ResizedImageField class CustomUser(AbstractUser): + email = models.EmailField(unique=True) # Override the email field with unique constraint 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) diff --git a/backend/server/users/serializers.py b/backend/server/users/serializers.py index 776bb78..25f5cc0 100644 --- a/backend/server/users/serializers.py +++ b/backend/server/users/serializers.py @@ -63,6 +63,11 @@ class RegisterSerializer(serializers.Serializer): def validate(self, data): if data['password1'] != data['password2']: raise serializers.ValidationError(_("The two password fields didn't match.")) + + # check if a user with the same email already exists + if User.objects.filter(email=data['email']).exists(): + raise serializers.ValidationError("This email is already in use.") + return data def custom_signup(self, request, user): diff --git a/documentation/docs/Installation/proxmox_lxc.md b/documentation/docs/Installation/proxmox_lxc.md new file mode 100644 index 0000000..5108fe8 --- /dev/null +++ b/documentation/docs/Installation/proxmox_lxc.md @@ -0,0 +1,8 @@ +--- +sidebar_position: 2 +--- + +# Proxmox LXC 🐧 + +AdventureLog can be installed in a Proxmox LXC container. This script created by the community will help you install AdventureLog in a Proxmox LXC container. +[Proxmox VE Helper-Scripts](https://community-scripts.github.io/ProxmoxVE/scripts?id=adventurelog) diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index 332efcb..eb340b1 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -2,6 +2,7 @@ import { createEventDispatcher } from 'svelte'; import type { Adventure, + Category, Collection, OpenStreetMapPlace, Point, @@ -17,7 +18,7 @@ export let latitude: number | null = null; export let collection: Collection | null = null; - import { DefaultMarker, FillLayer, MapEvents, MapLibre } from 'svelte-maplibre'; + import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre'; let query: string = ''; let places: OpenStreetMapPlace[] = []; @@ -25,9 +26,11 @@ let warningMessage: string = ''; let constrainDates: boolean = false; + let categories: Category[] = []; + import ActivityComplete from './ActivityComplete.svelte'; import { appVersion } from '$lib/config'; - import { ADVENTURE_TYPES } from '$lib'; + import CategoryDropdown from './CategoryDropdown.svelte'; let wikiError: string = ''; @@ -53,13 +56,7 @@ images: [], user_id: null, collection: collection?.id || null, - category: { - id: '', - name: '', - display_name: '', - icon: '', - user_id: '' - } + category: '' }; export let adventureToEdit: Adventure | null = null; @@ -81,13 +78,7 @@ collection: adventureToEdit?.collection || collection?.id || null, visits: adventureToEdit?.visits || [], is_visited: adventureToEdit?.is_visited || false, - category: adventureToEdit?.category || { - id: '', - name: '', - display_name: '', - icon: '', - user_id: '' - } + category: adventureToEdit?.category || '' }; let markers: Point[] = []; @@ -336,6 +327,12 @@ modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal.showModal(); console.log('open'); + let categoryFetch = await fetch('/api/categories/categories'); + if (categoryFetch.ok) { + categories = await categoryFetch.json(); + } else { + addToast('error', $t('adventures.category_fetch_error')); + } }); function close() { @@ -465,12 +462,8 @@
- {adventure.type.charAt(0).toUpperCase() + adventure.type.slice(1)} + {adventure.category.display_name} + {adventure.category.icon}
{#if adventure.visits.length > 0}diff --git a/frontend/src/routes/signup/+page.server.ts b/frontend/src/routes/signup/+page.server.ts index 1d3e6d0..ad24805 100644 --- a/frontend/src/routes/signup/+page.server.ts +++ b/frontend/src/routes/signup/+page.server.ts @@ -39,14 +39,6 @@ export const actions: Actions = { const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; const csrfTokenFetch = await event.fetch(`${serverEndpoint}/csrf/`); - // console log each form data - console.log('username: ', username); - console.log('password1: ', password1); - console.log('password2: ', password2); - console.log('email: ', email); - console.log('first_name: ', first_name); - console.log('last_name: ', last_name); - if (!csrfTokenFetch.ok) { event.locals.user = null; return fail(500, { message: 'Failed to fetch CSRF token' });