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 typing import Iterable
import uuid
from django.db import models
@ -102,6 +103,17 @@ class Adventure(models.Model):
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

View file

@ -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():
@ -63,11 +84,6 @@ class AdventureSerializer(CustomModelSerializer):
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)
@ -78,16 +94,13 @@ class AdventureSerializer(CustomModelSerializer):
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,11 +111,9 @@ 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()

View file

@ -117,7 +117,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
return Response({"error": f"Category {type} does not exist"}, status=400)
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(
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
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)

View file

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

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 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 />

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;
updated_at?: string | null;
is_visited?: boolean;
category: {
category:
| {
id: string;
name: string;
display_name: string;
icon: string;
user_id: string;
};
}
| string;
};
export type Country = {

View file

@ -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!",

View file

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

View file

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

View file

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