diff --git a/backend/server/achievements/migrations/0002_achievement_key_achievement_type.py b/backend/server/achievements/migrations/0002_achievement_key_achievement_type.py new file mode 100644 index 0000000..f97de29 --- /dev/null +++ b/backend/server/achievements/migrations/0002_achievement_key_achievement_type.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.8 on 2025-02-08 01:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('achievements', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='achievement', + name='key', + field=models.CharField(default='achievements.other', max_length=255, unique=True), + ), + migrations.AddField( + model_name='achievement', + name='type', + field=models.CharField(choices=[('adventure_count', 'adventure_count'), ('country_count', 'country_count')], default='adventure_count', max_length=255), + ), + ] diff --git a/backend/server/achievements/models.py b/backend/server/achievements/models.py index 8659bc2..c23bb31 100644 --- a/backend/server/achievements/models.py +++ b/backend/server/achievements/models.py @@ -1,3 +1,4 @@ +import uuid from django.db import models from django.contrib.auth import get_user_model @@ -11,8 +12,8 @@ VALID_ACHIEVEMENT_TYPES = [ class Achievement(models.Model): """Stores all possible achievements""" name = models.CharField(max_length=255, unique=True) - key = models.CharField(max_length=255, unique=True) # Used for frontend lookups, e.g. "achievements.first_adventure" - type = models.CharField(max_length=255) # adventure_count, country_count, etc. + key = models.CharField(max_length=255, unique=True, default='achievements.other') # Used for frontend lookups, e.g. "achievements.first_adventure" + type = models.CharField(max_length=255, choices=[(tag, tag) for tag in VALID_ACHIEVEMENT_TYPES], default='adventure_count') # adventure_count, country_count, etc. description = models.TextField() icon = models.ImageField(upload_to="achievements/", null=True, blank=True) condition = models.JSONField() # Stores rules like {"type": "adventure_count", "value": 10} diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 51c9bac..67b3d2e 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,7 +1,7 @@ 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, Category, Attachment, Hotel +from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category, Attachment, Lodging from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity from allauth.account.decorators import secure_admin_login @@ -140,7 +140,7 @@ admin.site.register(Category, CategoryAdmin) admin.site.register(City, CityAdmin) admin.site.register(VisitedCity) admin.site.register(Attachment) -admin.site.register(Hotel) +admin.site.register(Lodging) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/adventures/migrations/0023_lodging_delete_hotel.py b/backend/server/adventures/migrations/0023_lodging_delete_hotel.py new file mode 100644 index 0000000..44e502c --- /dev/null +++ b/backend/server/adventures/migrations/0023_lodging_delete_hotel.py @@ -0,0 +1,43 @@ +# Generated by Django 5.0.8 on 2025-02-08 01:50 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0022_hotel'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Lodging', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=200)), + ('type', models.CharField(choices=[('hotel', 'Hotel'), ('hostel', 'Hostel'), ('resort', 'Resort'), ('bnb', 'Bed & Breakfast'), ('campground', 'Campground'), ('cabin', 'Cabin'), ('apartment', 'Apartment'), ('house', 'House'), ('villa', 'Villa'), ('motel', 'Motel'), ('other', 'Other')], default='other', max_length=100)), + ('description', models.TextField(blank=True, null=True)), + ('rating', models.FloatField(blank=True, null=True)), + ('link', models.URLField(blank=True, max_length=2083, null=True)), + ('check_in', models.DateTimeField(blank=True, null=True)), + ('check_out', models.DateTimeField(blank=True, null=True)), + ('reservation_number', models.CharField(blank=True, max_length=100, null=True)), + ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True)), + ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('location', models.CharField(blank=True, max_length=200, null=True)), + ('is_public', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('collection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.collection')), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.DeleteModel( + name='Hotel', + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 96d439b..ad52e89 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -36,6 +36,20 @@ ADVENTURE_TYPES = [ ('other', 'Other') ] +LODGING_TYPES = [ + ('hotel', 'Hotel'), + ('hostel', 'Hostel'), + ('resort', 'Resort'), + ('bnb', 'Bed & Breakfast'), + ('campground', 'Campground'), + ('cabin', 'Cabin'), + ('apartment', 'Apartment'), + ('house', 'House'), + ('villa', 'Villa'), + ('motel', 'Motel'), + ('other', 'Other') +] + TRANSPORTATION_TYPES = [ ('car', 'Car'), ('plane', 'Plane'), @@ -320,11 +334,12 @@ class Category(models.Model): def __str__(self): return self.name + ' - ' + self.display_name + ' - ' + self.icon -class Hotel(models.Model): +class Lodging(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) + type = models.CharField(max_length=100, choices=LODGING_TYPES, default='other') description = models.TextField(blank=True, null=True) rating = models.FloatField(blank=True, null=True) link = models.URLField(blank=True, null=True, max_length=2083) @@ -346,9 +361,9 @@ class Hotel(models.Model): if self.collection: if self.collection.is_public and not self.is_public: - raise ValidationError('Hotels associated with a public collection must be public. Collection: ' + self.collection.name + ' Hotel: ' + self.name) + raise ValidationError('Lodging associated with a public collection must be public. Collection: ' + self.collection.name + ' Loging: ' + self.name) if self.user_id != self.collection.user_id: - raise ValidationError('Hotels must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Hotel owner: ' + self.user_id.username) + raise ValidationError('Lodging must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Lodging owner: ' + self.user_id.username) 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 5771c7c..97dd633 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, Category, Attachment, Hotel +from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment, Lodging from rest_framework import serializers from main.utils import CustomModelSerializer from users.serializers import CustomUserDetailsSerializer @@ -203,14 +203,14 @@ class TransportationSerializer(CustomModelSerializer): ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] -class HotelSerializer(CustomModelSerializer): +class LodgingSerializer(CustomModelSerializer): class Meta: - model = Hotel + model = Lodging fields = [ 'id', 'user_id', 'name', 'description', 'rating', 'link', 'check_in', 'check_out', 'reservation_number', 'price', 'latitude', 'longitude', 'location', 'is_public', - 'collection', 'created_at', 'updated_at' + 'collection', 'created_at', 'updated_at', 'type' ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] @@ -300,11 +300,11 @@ class CollectionSerializer(CustomModelSerializer): transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set') notes = NoteSerializer(many=True, read_only=True, source='note_set') checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set') - hotels = HotelSerializer(many=True, read_only=True, source='hotel_set') + lodging = LodgingSerializer(many=True, read_only=True, source='lodging_set') class Meta: model = Collection - fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'hotels'] + fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'lodging'] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] def to_representation(self, instance): diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 3c7eff8..1a98273 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -18,7 +18,7 @@ router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-cale router.register(r'overpass', OverpassViewSet, basename='overpass') router.register(r'search', GlobalSearchView, basename='search') router.register(r'attachments', AttachmentViewSet, basename='attachments') -router.register(r'hotels', HotelViewSet, basename='hotels') +router.register(r'lodging', LodgingViewSet, basename='lodging') urlpatterns = [ diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 957af52..8f531f7 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -13,4 +13,4 @@ from .stats_view import * from .transportation_view import * from .global_search_view import * from .attachment_view import * -from .hotel_view import * \ No newline at end of file +from .lodging_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/hotel_view.py b/backend/server/adventures/views/lodging_view.py similarity index 91% rename from backend/server/adventures/views/hotel_view.py rename to backend/server/adventures/views/lodging_view.py index 4f5b0eb..16114ba 100644 --- a/backend/server/adventures/views/hotel_view.py +++ b/backend/server/adventures/views/lodging_view.py @@ -2,21 +2,21 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from django.db.models import Q -from adventures.models import Hotel -from adventures.serializers import HotelSerializer +from adventures.models import Lodging +from adventures.serializers import LodgingSerializer from rest_framework.exceptions import PermissionDenied from adventures.permissions import IsOwnerOrSharedWithFullAccess from rest_framework.permissions import IsAuthenticated -class HotelViewSet(viewsets.ModelViewSet): - queryset = Hotel.objects.all() - serializer_class = HotelSerializer +class LodgingViewSet(viewsets.ModelViewSet): + queryset = Lodging.objects.all() + serializer_class = LodgingSerializer permission_classes = [IsOwnerOrSharedWithFullAccess] def list(self, request, *args, **kwargs): if not request.user.is_authenticated: return Response(status=status.HTTP_403_FORBIDDEN) - queryset = Hotel.objects.filter( + queryset = Lodging.objects.filter( Q(user_id=request.user.id) ) serializer = self.get_serializer(queryset, many=True) @@ -26,11 +26,11 @@ class HotelViewSet(viewsets.ModelViewSet): user = self.request.user if self.action == 'retrieve': # For individual adventure retrieval, include public adventures, user's own adventures and shared adventures - return Hotel.objects.filter( + return Lodging.objects.filter( Q(is_public=True) | Q(user_id=user.id) | Q(collection__shared_with=user.id) ).distinct().order_by('-updated_at') # For other actions, include user's own adventures and shared adventures - return Hotel.objects.filter( + return Lodging.objects.filter( Q(user_id=user.id) | Q(collection__shared_with=user.id) ).distinct().order_by('-updated_at') diff --git a/documentation/.vitepress/config.mts b/documentation/.vitepress/config.mts index 5638acf..dbd9299 100644 --- a/documentation/.vitepress/config.mts +++ b/documentation/.vitepress/config.mts @@ -178,6 +178,8 @@ export default defineConfig({ { icon: "github", link: "https://github.com/seanmorley15/AdventureLog" }, { icon: "discord", link: "https://discord.gg/wRbQ9Egr8C" }, { icon: "buymeacoffee", link: "https://buymeacoffee.com/seanmorley15" }, + { icon: "x", link: "https://x.com/AdventureLogApp" }, + { icon: "mastodon", link: "https://mastodon.social/@adventurelog" }, ], }, }); diff --git a/frontend/src/lib/components/LocationDropdown.svelte b/frontend/src/lib/components/LocationDropdown.svelte index 42328f4..83a2837 100644 --- a/frontend/src/lib/components/LocationDropdown.svelte +++ b/frontend/src/lib/components/LocationDropdown.svelte @@ -1,11 +1,11 @@ + +{#if isWarningModalOpen} + (isWarningModalOpen = false)} + on:confirm={deleteTransportation} + /> +{/if} + +
+
+ +
+

{lodging.name}

+
+
+ {lodging.type} +
+ +
+
+ {#if unlinked} +
{$t('adventures.out_of_range')}
+ {/if} + + +
+ {#if lodging.location} +
+ {$t('adventures.from')}: +

{lodging.location}

+
+ {/if} + {#if lodging.check_in && lodging.check_out} +
+ {$t('adventures.start')}: +

{new Date(lodging.check_in).toLocaleDateString(undefined, { timeZone: 'UTC' })}

+
+ {/if} +
+ + +
+ {#if lodging.location} + +
+ {$t('adventures.to')}: + +

{lodging.location}

+
+ {/if} + {#if lodging.check_out} +
+ {$t('adventures.end')}: +

{new Date(lodging.check_out).toLocaleDateString(undefined, { timeZone: 'UTC' })}

+
+ {/if} +
+ + + {#if lodging.user_id == user?.uuid || (collection && user && collection.shared_with && collection.shared_with.includes(user.uuid))} +
+ + +
+ {/if} +
+
diff --git a/frontend/src/lib/components/HotelModal.svelte b/frontend/src/lib/components/LodgingModal.svelte similarity index 72% rename from frontend/src/lib/components/HotelModal.svelte rename to frontend/src/lib/components/LodgingModal.svelte index 241be48..f4a5c66 100644 --- a/frontend/src/lib/components/HotelModal.svelte +++ b/frontend/src/lib/components/LodgingModal.svelte @@ -3,27 +3,19 @@ import { addToast } from '$lib/toasts'; import { t } from 'svelte-i18n'; import MarkdownEditor from './MarkdownEditor.svelte'; - import { appVersion } from '$lib/config'; - import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre'; - import type { Collection, Hotel, ReverseGeocode, OpenStreetMapPlace, Point } from '$lib/types'; + import type { Collection, Lodging } from '$lib/types'; import LocationDropdown from './LocationDropdown.svelte'; const dispatch = createEventDispatcher(); export let collection: Collection; - export let hotelToEdit: Hotel | null = null; + export let lodgingToEdit: Lodging | null = null; let modal: HTMLDialogElement; let constrainDates: boolean = false; - let hotel: Hotel = { ...initializeHotel(hotelToEdit) }; + let lodging: Lodging = { ...initializeLodging(lodgingToEdit) }; let fullStartDate: string = ''; let fullEndDate: string = ''; - let reverseGeocodePlace: any | null = null; - let query: string = ''; - let places: OpenStreetMapPlace[] = []; - let noPlaces: boolean = false; - let is_custom_location: boolean = false; - let markers: Point[] = []; // Format date as local datetime function toLocalDatetime(value: string | null): string { @@ -32,12 +24,32 @@ return date.toISOString().slice(0, 16); // Format: YYYY-MM-DDTHH:mm } + type LodgingType = { + value: string; + label: string; + }; + + const LODGING_TYPES: LodgingType[] = [ + { value: 'hotel', label: 'Hotel' }, + { value: 'hostel', label: 'Hostel' }, + { value: 'resort', label: 'Resort' }, + { value: 'bnb', label: 'Bed & Breakfast' }, + { value: 'campground', label: 'Campground' }, + { value: 'cabin', label: 'Cabin' }, + { value: 'apartment', label: 'Apartment' }, + { value: 'house', label: 'House' }, + { value: 'villa', label: 'Villa' }, + { value: 'motel', label: 'Motel' }, + { value: 'other', label: 'Other' } + ]; + // Initialize hotel with values from hotelToEdit or default values - function initializeHotel(hotelToEdit: Hotel | null): Hotel { + function initializeLodging(hotelToEdit: Lodging | null): Lodging { return { id: hotelToEdit?.id || '', user_id: hotelToEdit?.user_id || '', name: hotelToEdit?.name || '', + type: hotelToEdit?.type || 'other', description: hotelToEdit?.description || '', rating: hotelToEdit?.rating || NaN, link: hotelToEdit?.link || '', @@ -49,7 +61,7 @@ longitude: hotelToEdit?.longitude || null, location: hotelToEdit?.location || '', is_public: hotelToEdit?.is_public || false, - collection: hotelToEdit?.collection || '', + collection: hotelToEdit?.collection || collection.id, created_at: hotelToEdit?.created_at || '', updated_at: hotelToEdit?.updated_at || '' }; @@ -63,8 +75,8 @@ // Handle rating change $: { - if (!hotel.rating) { - hotel.rating = NaN; + if (!lodging.rating) { + lodging.rating = NaN; } } @@ -88,35 +100,37 @@ async function handleSubmit(event: Event) { event.preventDefault(); - if (hotel.check_in && !hotel.check_out) { - const checkInDate = new Date(hotel.check_in); + if (lodging.check_in && !lodging.check_out) { + const checkInDate = new Date(lodging.check_in); checkInDate.setDate(checkInDate.getDate() + 1); - hotel.check_out = checkInDate.toISOString(); + lodging.check_out = checkInDate.toISOString(); } - if (hotel.check_in && hotel.check_out && hotel.check_in > hotel.check_out) { + if (lodging.check_in && lodging.check_out && lodging.check_in > lodging.check_out) { addToast('error', $t('adventures.start_before_end_error')); return; } // Create or update hotel - const url = hotel.id === '' ? '/api/hotels' : `/api/hotels/${hotel.id}`; - const method = hotel.id === '' ? 'POST' : 'PATCH'; + const url = lodging.id === '' ? '/api/lodging' : `/api/lodging/${lodging.id}`; + const method = lodging.id === '' ? 'POST' : 'PATCH'; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(hotel) + body: JSON.stringify(lodging) }); const data = await res.json(); if (data.id) { - hotel = data as Hotel; + lodging = data as Lodging; const toastMessage = - hotel.id === '' ? 'adventures.adventure_created' : 'adventures.adventure_updated'; + lodging.id === '' ? 'adventures.adventure_created' : 'adventures.adventure_updated'; addToast('success', $t(toastMessage)); - dispatch('save', hotel); + dispatch('save', lodging); } else { const errorMessage = - hotel.id === '' ? 'adventures.adventure_create_error' : 'adventures.adventure_update_error'; + lodging.id === '' + ? 'adventures.adventure_create_error' + : 'adventures.adventure_update_error'; addToast('error', $t(errorMessage)); } } @@ -127,9 +141,7 @@