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:
parent
86d213bb8b
commit
736ede2417
15 changed files with 216 additions and 60 deletions
|
@ -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')},
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
8
documentation/docs/Installation/proxmox_lxc.md
Normal file
8
documentation/docs/Installation/proxmox_lxc.md
Normal 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)
|
|
@ -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 @@
|
|||
</div>
|
||||
<div>
|
||||
<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>
|
||||
{#each ADVENTURE_TYPES as type}
|
||||
<option value={type.type}>{type.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<CategoryDropdown bind:categories bind:category_id={adventure.category} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="rating">{$t('adventures.rating')}</label><br />
|
||||
|
|
82
frontend/src/lib/components/CategoryDropdown.svelte
Normal file
82
frontend/src/lib/components/CategoryDropdown.svelte
Normal 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>
|
|
@ -38,13 +38,15 @@ export type Adventure = {
|
|||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
is_visited?: boolean;
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
icon: string;
|
||||
user_id: string;
|
||||
};
|
||||
category:
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
icon: string;
|
||||
user_id: string;
|
||||
}
|
||||
| string;
|
||||
};
|
||||
|
||||
export type Country = {
|
||||
|
|
|
@ -178,6 +178,7 @@
|
|||
"adventure_updated": "Adventure updated",
|
||||
"adventure_update_error": "Failed to update adventure",
|
||||
"set_to_pin": "Set to Pin",
|
||||
"category_fetch_error": "Error fetching categories",
|
||||
"new_adventure": "New Adventure",
|
||||
"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!",
|
||||
|
|
|
@ -39,6 +39,8 @@ export const load = (async (event) => {
|
|||
);
|
||||
|
||||
if (!initialFetch.ok) {
|
||||
let error_message = await initialFetch.json();
|
||||
console.error(error_message);
|
||||
console.error('Failed to fetch visited adventures');
|
||||
return redirect(302, '/login');
|
||||
} else {
|
||||
|
|
|
@ -337,7 +337,8 @@
|
|||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="text-lg text-black font-bold">{adventure.name}</div>
|
||||
<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>
|
||||
{#if adventure.visits.length > 0}
|
||||
<p class="text-black text-sm">
|
||||
|
|
|
@ -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' });
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue