1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 12:59:36 +02:00

Enhance adventure management: add error handling for category fetch, implement unique email constraint in user model, and update adventure save logic to ensure category assignment

This commit is contained in:
Sean Morley 2024-11-22 17:03:02 -05:00
parent 86d213bb8b
commit 736ede2417
15 changed files with 216 additions and 60 deletions

View file

@ -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')},
),
]

View file

@ -1,4 +1,5 @@
from collections.abc import Collection from collections.abc import Collection
from typing import Iterable
import uuid import uuid
from django.db import models from django.db import models
@ -101,6 +102,17 @@ class Adventure(models.Model):
if self.category: if self.category:
if self.user_id != self.category.user_id: 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) 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): def __str__(self):
return self.name return self.name

View file

@ -37,7 +37,7 @@ class CategorySerializer(CustomModelSerializer):
def get_num_adventures(self, obj): def get_num_adventures(self, obj):
return Adventure.objects.filter(category=obj, user_id=obj.user_id).count() return Adventure.objects.filter(category=obj, user_id=obj.user_id).count()
class VisitSerializer(CustomModelSerializer): class VisitSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Visit model = Visit
@ -47,13 +47,34 @@ class VisitSerializer(CustomModelSerializer):
class AdventureSerializer(CustomModelSerializer): class AdventureSerializer(CustomModelSerializer):
images = AdventureImageSerializer(many=True, read_only=True) images = AdventureImageSerializer(many=True, read_only=True)
visits = VisitSerializer(many=True, read_only=False) 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() is_visited = serializers.SerializerMethodField()
class Meta: class Meta:
model = Adventure 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'] 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): def get_is_visited(self, obj):
current_date = timezone.now().date() current_date = timezone.now().date()
for visit in obj.visits.all(): 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): elif visit.start_date and not visit.end_date and (visit.start_date <= current_date):
return True return True
return False return False
def to_representation(self, instance):
representation = super().to_representation(instance)
return representation
def create(self, validated_data): def create(self, validated_data):
visits_data = validated_data.pop('visits', []) visits_data = validated_data.pop('visits', [])
adventure = Adventure.objects.create(**validated_data) adventure = Adventure.objects.create(**validated_data)
for visit_data in visits_data: for visit_data in visits_data:
Visit.objects.create(adventure=adventure, **visit_data) Visit.objects.create(adventure=adventure, **visit_data)
return adventure return adventure
def update(self, instance, validated_data): def update(self, instance, validated_data):
visits_data = validated_data.pop('visits', []) visits_data = validated_data.pop('visits', [])
# Update Adventure fields
for attr, value in validated_data.items(): for attr, value in validated_data.items():
setattr(instance, attr, value) setattr(instance, attr, value)
instance.save() instance.save()
# Get current visits
current_visits = instance.visits.all() current_visits = instance.visits.all()
current_visit_ids = set(current_visits.values_list('id', flat=True)) current_visit_ids = set(current_visits.values_list('id', flat=True))
# Update or create visits
updated_visit_ids = set() updated_visit_ids = set()
for visit_data in visits_data: for visit_data in visits_data:
visit_id = visit_data.get('id') visit_id = visit_data.get('id')
@ -98,16 +111,14 @@ class AdventureSerializer(CustomModelSerializer):
visit.save() visit.save()
updated_visit_ids.add(visit_id) updated_visit_ids.add(visit_id)
else: else:
# If no ID is provided or ID doesn't exist, create new visit
new_visit = Visit.objects.create(adventure=instance, **visit_data) new_visit = Visit.objects.create(adventure=instance, **visit_data)
updated_visit_ids.add(new_visit.id) 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 visits_to_delete = current_visit_ids - updated_visit_ids
instance.visits.filter(id__in=visits_to_delete).delete() instance.visits.filter(id__in=visits_to_delete).delete()
return instance return instance
class TransportationSerializer(CustomModelSerializer): class TransportationSerializer(CustomModelSerializer):
class Meta: class Meta:

View file

@ -116,8 +116,8 @@ class AdventureViewSet(viewsets.ModelViewSet):
if not Category.objects.filter(user_id=request.user, name=type).exists(): if not Category.objects.filter(user_id=request.user, name=type).exists():
return Response({"error": f"Category {type} does not exist"}, status=400) return Response({"error": f"Category {type} does not exist"}, status=400)
if not types: if not types:
return Response({"error": "No valid types provided"}, status=400) return Response({"error": "At least one type must be provided"}, status=400)
queryset = Adventure.objects.filter( queryset = Adventure.objects.filter(
category__in=Category.objects.filter(name__in=types, user_id=request.user), category__in=Category.objects.filter(name__in=types, user_id=request.user),

View file

@ -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),
),
]

View file

@ -4,6 +4,7 @@ from django.db import models
from django_resized import ResizedImageField from django_resized import ResizedImageField
class CustomUser(AbstractUser): 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/') 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) uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
public_profile = models.BooleanField(default=False) public_profile = models.BooleanField(default=False)

View file

@ -63,6 +63,11 @@ class RegisterSerializer(serializers.Serializer):
def validate(self, data): def validate(self, data):
if data['password1'] != data['password2']: if data['password1'] != data['password2']:
raise serializers.ValidationError(_("The two password fields didn't match.")) 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 return data
def custom_signup(self, request, user): def custom_signup(self, request, user):

View file

@ -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)

View file

@ -2,6 +2,7 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import type { import type {
Adventure, Adventure,
Category,
Collection, Collection,
OpenStreetMapPlace, OpenStreetMapPlace,
Point, Point,
@ -17,7 +18,7 @@
export let latitude: number | null = null; export let latitude: number | null = null;
export let collection: Collection | 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 query: string = '';
let places: OpenStreetMapPlace[] = []; let places: OpenStreetMapPlace[] = [];
@ -25,9 +26,11 @@
let warningMessage: string = ''; let warningMessage: string = '';
let constrainDates: boolean = false; let constrainDates: boolean = false;
let categories: Category[] = [];
import ActivityComplete from './ActivityComplete.svelte'; import ActivityComplete from './ActivityComplete.svelte';
import { appVersion } from '$lib/config'; import { appVersion } from '$lib/config';
import { ADVENTURE_TYPES } from '$lib'; import CategoryDropdown from './CategoryDropdown.svelte';
let wikiError: string = ''; let wikiError: string = '';
@ -53,13 +56,7 @@
images: [], images: [],
user_id: null, user_id: null,
collection: collection?.id || null, collection: collection?.id || null,
category: { category: ''
id: '',
name: '',
display_name: '',
icon: '',
user_id: ''
}
}; };
export let adventureToEdit: Adventure | null = null; export let adventureToEdit: Adventure | null = null;
@ -81,13 +78,7 @@
collection: adventureToEdit?.collection || collection?.id || null, collection: adventureToEdit?.collection || collection?.id || null,
visits: adventureToEdit?.visits || [], visits: adventureToEdit?.visits || [],
is_visited: adventureToEdit?.is_visited || false, is_visited: adventureToEdit?.is_visited || false,
category: adventureToEdit?.category || { category: adventureToEdit?.category || ''
id: '',
name: '',
display_name: '',
icon: '',
user_id: ''
}
}; };
let markers: Point[] = []; let markers: Point[] = [];
@ -336,6 +327,12 @@
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal.showModal(); modal.showModal();
console.log('open'); 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() { function close() {
@ -465,12 +462,8 @@
</div> </div>
<div> <div>
<label for="link">{$t('adventures.category')}</label><br /> <label for="link">{$t('adventures.category')}</label><br />
<select class="select select-bordered w-full max-w-xs" bind:value={adventure.type}>
<option disabled selected>{$t('adventures.select_adventure_category')}</option> <CategoryDropdown bind:categories bind:category_id={adventure.category} />
{#each ADVENTURE_TYPES as type}
<option value={type.type}>{type.label}</option>
{/each}
</select>
</div> </div>
<div> <div>
<label for="rating">{$t('adventures.rating')}</label><br /> <label for="rating">{$t('adventures.rating')}</label><br />

View file

@ -0,0 +1,82 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Category } from '$lib/types';
import { t } from 'svelte-i18n';
export let categories: Category[] = [];
let selected_category: Category | null = null;
export let category_id:
| {
id: string;
name: string;
display_name: string;
icon: string;
user_id: string;
}
| string;
let isOpen = false;
function toggleDropdown() {
isOpen = !isOpen;
}
function selectCategory(category: Category) {
selected_category = category;
category_id = category.id;
isOpen = false;
}
function removeCategory(categoryName: string) {
categories = categories.filter((category) => category.name !== categoryName);
if (selected_category && selected_category.name === categoryName) {
selected_category = null;
}
}
// Close dropdown when clicking outside
let dropdownRef: HTMLDivElement;
onMount(() => {
if (category_id) {
// when category_id is passed, it will be the full object not just the id that is why we can use it directly as selected_category
selected_category = category_id as Category;
}
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
isOpen = false;
}
};
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
});
</script>
<div class="mt-2 relative" bind:this={dropdownRef}>
<button type="button" class="btn btn-outline w-full text-left" on:click={toggleDropdown}>
{selected_category
? selected_category.display_name + ' ' + selected_category.icon
: 'Select Category'}
</button>
{#if isOpen}
<div class="absolute z-10 w-full mt-1 bg-base-300 rounded shadow-lg p-2 flex flex-wrap gap-2">
{#each categories as category}
<div
class="btn btn-neutral flex items-center space-x-2"
on:click={() => selectCategory(category)}
>
<span>{category.display_name} {category.icon}</span>
<button
type="button"
class="btn btn-xs btn-error"
on:click|stopPropagation={() => removeCategory(category.name)}
>
{$t('adventures.remove')}
</button>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -38,13 +38,15 @@ export type Adventure = {
created_at?: string | null; created_at?: string | null;
updated_at?: string | null; updated_at?: string | null;
is_visited?: boolean; is_visited?: boolean;
category: { category:
id: string; | {
name: string; id: string;
display_name: string; name: string;
icon: string; display_name: string;
user_id: string; icon: string;
}; user_id: string;
}
| string;
}; };
export type Country = { export type Country = {

View file

@ -178,6 +178,7 @@
"adventure_updated": "Adventure updated", "adventure_updated": "Adventure updated",
"adventure_update_error": "Failed to update adventure", "adventure_update_error": "Failed to update adventure",
"set_to_pin": "Set to Pin", "set_to_pin": "Set to Pin",
"category_fetch_error": "Error fetching categories",
"new_adventure": "New Adventure", "new_adventure": "New Adventure",
"basic_information": "Basic Information", "basic_information": "Basic Information",
"adventure_not_found": "There are no adventures to display. Add some using the plus button at the bottom right or try changing filters!", "adventure_not_found": "There are no adventures to display. Add some using the plus button at the bottom right or try changing filters!",

View file

@ -39,6 +39,8 @@ export const load = (async (event) => {
); );
if (!initialFetch.ok) { if (!initialFetch.ok) {
let error_message = await initialFetch.json();
console.error(error_message);
console.error('Failed to fetch visited adventures'); console.error('Failed to fetch visited adventures');
return redirect(302, '/login'); return redirect(302, '/login');
} else { } else {

View file

@ -337,7 +337,8 @@
<Popup openOn="click" offset={[0, -10]}> <Popup openOn="click" offset={[0, -10]}>
<div class="text-lg text-black font-bold">{adventure.name}</div> <div class="text-lg text-black font-bold">{adventure.name}</div>
<p class="font-semibold text-black text-md"> <p class="font-semibold text-black text-md">
{adventure.type.charAt(0).toUpperCase() + adventure.type.slice(1)} {adventure.category.display_name}
{adventure.category.icon}
</p> </p>
{#if adventure.visits.length > 0} {#if adventure.visits.length > 0}
<p class="text-black text-sm"> <p class="text-black text-sm">

View file

@ -39,14 +39,6 @@ export const actions: Actions = {
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
const csrfTokenFetch = await event.fetch(`${serverEndpoint}/csrf/`); 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) { if (!csrfTokenFetch.ok) {
event.locals.user = null; event.locals.user = null;
return fail(500, { message: 'Failed to fetch CSRF token' }); return fail(500, { message: 'Failed to fetch CSRF token' });