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

feat: Implement Hotel model with CRUD operations and integrate into views and serializers

This commit is contained in:
Sean Morley 2025-02-03 12:28:42 -05:00
parent da9c2ba80a
commit df60184f23
7 changed files with 451 additions and 6 deletions

View file

@ -1,6 +1,6 @@
from django.utils import timezone from django.utils import timezone
import os import os
from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment, Hotel
from rest_framework import serializers from rest_framework import serializers
from main.utils import CustomModelSerializer from main.utils import CustomModelSerializer
from users.serializers import CustomUserDetailsSerializer from users.serializers import CustomUserDetailsSerializer
@ -304,3 +304,13 @@ class CollectionSerializer(CustomModelSerializer):
representation['shared_with'] = shared_uuids representation['shared_with'] = shared_uuids
return representation return representation
class HotelSerializer(CustomModelSerializer):
class Meta:
model = Hotel
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'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']

View file

@ -18,6 +18,7 @@ router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-cale
router.register(r'overpass', OverpassViewSet, basename='overpass') router.register(r'overpass', OverpassViewSet, basename='overpass')
router.register(r'search', GlobalSearchView, basename='search') router.register(r'search', GlobalSearchView, basename='search')
router.register(r'attachments', AttachmentViewSet, basename='attachments') router.register(r'attachments', AttachmentViewSet, basename='attachments')
router.register(r'hotels', HotelViewSet, basename='hotels')
urlpatterns = [ urlpatterns = [

View file

@ -13,3 +13,4 @@ from .stats_view import *
from .transportation_view import * from .transportation_view import *
from .global_search_view import * from .global_search_view import *
from .attachment_view import * from .attachment_view import *
from .hotel_view import *

View file

@ -0,0 +1,84 @@
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 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
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(
Q(user_id=request.user.id)
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def get_queryset(self):
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(
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(
Q(user_id=user.id) | Q(collection__shared_with=user.id)
).distinct().order_by('-updated_at')
def partial_update(self, request, *args, **kwargs):
# Retrieve the current object
instance = self.get_object()
user = request.user
# Partially update the instance with the request data
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Retrieve the collection from the validated data
new_collection = serializer.validated_data.get('collection')
if new_collection is not None and new_collection != instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
self.perform_update(serializer)
# Return the updated instance
return Response(serializer.data)
def perform_update(self, serializer):
serializer.save()
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
def perform_create(self, serializer):
# Retrieve the collection from the validated data
collection = serializer.validated_data.get('collection')
# Check if a collection is provided
if collection:
user = self.request.user
# Check if the user is the owner or is in the shared_with list
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
# Return an error response if the user does not have permission
raise PermissionDenied("You do not have permission to use this collection.")
# if collection the owner of the adventure is the owner of the collection
serializer.save(user_id=collection.user_id)
return
# Save the adventure with the current user as the owner
serializer.save(user_id=self.request.user)

View file

@ -35,6 +35,10 @@ This configuration is done in the [Admin Panel](../../guides/admin_panel.md). Yo
- Settings: can be left blank - Settings: can be left blank
- Sites: move over the sites you want to enable Authentik on, usually `example.com` and `www.example.com` unless you renamed your sites. - Sites: move over the sites you want to enable Authentik on, usually `example.com` and `www.example.com` unless you renamed your sites.
4. Save the configuration.
Users should now be able to log in to AdventureLog using their GitHub account, and link it to existing accounts.
## Linking to Existing Account ## Linking to Existing Account
If a user has an existing AdventureLog account and wants to link it to their Github account, they can do so by logging in to their AdventureLog account and navigating to the `Settings` page. There is a button that says `Launch Account Connections`, click that and then choose the provider to link to the existing account. If a user has an existing AdventureLog account and wants to link it to their Github account, they can do so by logging in to their AdventureLog account and navigating to the `Settings` page. There is a button that says `Launch Account Connections`, click that and then choose the provider to link to the existing account.
@ -43,6 +47,3 @@ If a user has an existing AdventureLog account and wants to link it to their Git
![Authentik Social Auth Configuration](/github_settings.png) ![Authentik Social Auth Configuration](/github_settings.png)
4. Save the configuration.
Users should now be able to log in to AdventureLog using their GitHub account, and link it to existing accounts.

View file

@ -0,0 +1,328 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Collection, Hotel } from '$lib/types';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { addToast } from '$lib/toasts';
let modal: HTMLDialogElement;
import { t } from 'svelte-i18n';
import MarkdownEditor from './MarkdownEditor.svelte';
import { appVersion } from '$lib/config';
import { DefaultMarker, MapLibre } from 'svelte-maplibre';
export let collection: Collection;
export let hotelToEdit: Hotel | null = null;
let constrainDates: boolean = false;
function toLocalDatetime(value: string | null): string {
if (!value) return '';
const date = new Date(value);
return date.toISOString().slice(0, 16); // Format: YYYY-MM-DDTHH:mm
}
let hotel: Hotel = {
id: hotelToEdit?.id || '',
user_id: hotelToEdit?.user_id || '',
name: hotelToEdit?.name || '',
description: hotelToEdit?.description || '',
rating: hotelToEdit?.rating || NaN,
link: hotelToEdit?.link || '',
check_in: hotelToEdit?.check_in || null,
check_out: hotelToEdit?.check_out || null,
reservation_number: hotelToEdit?.reservation_number || '',
price: hotelToEdit?.price || null,
latitude: hotelToEdit?.latitude || null,
longitude: hotelToEdit?.longitude || null,
location: hotelToEdit?.location || '',
is_public: hotelToEdit?.is_public || false,
collection: hotelToEdit?.collection || '',
created_at: hotelToEdit?.created_at || '',
updated_at: hotelToEdit?.updated_at || ''
};
let fullStartDate: string = '';
let fullEndDate: string = '';
if (collection.start_date && collection.end_date) {
fullStartDate = `${collection.start_date}T00:00`;
fullEndDate = `${collection.end_date}T23:59`;
}
$: {
if (!hotel.rating) {
hotel.rating = NaN;
}
}
console.log(hotel);
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
async function handleSubmit(event: Event) {
event.preventDefault();
console.log(hotel);
if (hotel.check_in && !hotel.check_out) {
const checkInDate = new Date(hotel.check_in);
checkInDate.setDate(checkInDate.getDate() + 1);
hotel.check_out = checkInDate.toISOString();
}
if (hotel.check_in && hotel.check_out && hotel.check_in > hotel.check_out) {
addToast('error', $t('adventures.start_before_end_error'));
return;
}
if (hotel.id === '') {
let res = await fetch('/api/hotels', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(hotel)
});
let data = await res.json();
if (data.id) {
hotel = data as Hotel;
addToast('success', $t('adventures.adventure_created'));
dispatch('save', hotel);
} else {
console.error(data);
addToast('error', $t('adventures.adventure_create_error'));
}
} else {
let res = await fetch(`/api/hotels/${hotel.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(hotel)
});
let data = await res.json();
if (data.id) {
hotel = data as Hotel;
addToast('success', $t('adventures.adventure_updated'));
dispatch('save', hotel);
} else {
addToast('error', $t('adventures.adventure_update_error'));
}
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{hotelToEdit
? $t('transportation.edit_transportation')
: $t('transportation.new_transportation')}
</h3>
<div class="modal-action items-center">
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<!-- Name -->
<div>
<label for="name">
{$t('adventures.name')}<span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={hotel.name}
class="input input-bordered w-full"
required
/>
</div>
<!-- Description -->
<div>
<label for="description">{$t('adventures.description')}</label><br />
<MarkdownEditor bind:text={hotel.description} editor_height={'h-32'} />
</div>
<!-- Rating -->
<div>
<label for="rating">{$t('adventures.rating')}</label><br />
<input
type="number"
min="0"
max="5"
hidden
bind:value={hotel.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="rating -ml-3 mt-1">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(hotel.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (hotel.rating = 1)}
checked={hotel.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (hotel.rating = 2)}
checked={hotel.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (hotel.rating = 3)}
checked={hotel.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (hotel.rating = 4)}
checked={hotel.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (hotel.rating = 5)}
checked={hotel.rating === 5}
/>
{#if hotel.rating}
<button
type="button"
class="btn btn-sm btn-error ml-2"
on:click={() => (hotel.rating = NaN)}
>
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
<!-- Link -->
<div>
<label for="link">{$t('adventures.link')}</label>
<input
type="url"
id="link"
name="link"
bind:value={hotel.link}
class="input input-bordered w-full"
/>
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.date_information')}
</div>
<div class="collapse-content">
<!-- Start Date -->
<div>
<label for="date">
{$t('adventures.start_date')}
</label>
{#if collection && collection.start_date && collection.end_date}<label
class="label cursor-pointer flex items-start space-x-2"
>
<span class="label-text">{$t('adventures.date_constrain')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
on:change={() => (constrainDates = !constrainDates)}
/></label
>
{/if}
<div>
<input
type="datetime-local"
id="date"
name="date"
bind:value={hotel.check_in}
min={constrainDates ? fullStartDate : ''}
max={constrainDates ? fullEndDate : ''}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
<!-- End Date -->
{#if hotel.check_in}
<div>
<label for="end_date">
{$t('adventures.end_date')}
</label>
<div>
<input
type="datetime-local"
id="end_date"
name="end_date"
min={constrainDates ? hotel.check_in : ''}
max={constrainDates ? fullEndDate : ''}
bind:value={hotel.check_out}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
{/if}
</div>
</div>
<!-- Location Information -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.location_information')}
</div>
<div class="collapse-content"></div>
</div>
<!-- Form Actions -->
<div class="mt-4">
<button type="submit" class="btn btn-primary">
{$t('notes.save')}
</button>
<button type="button" class="btn" on:click={close}>
{$t('about.close')}
</button>
</div>
</form>
</div>
</div>
</dialog>

View file

@ -262,3 +262,23 @@ export type Attachment = {
user_id: string; user_id: string;
name: string; name: string;
}; };
export type Hotel = {
id: string;
user_id: string;
name: string;
description: string | null;
rating: number | null;
link: string | null;
check_in: string | null; // ISO 8601 date string
check_out: string | null; // ISO 8601 date string
reservation_number: string | null;
price: number | null;
latitude: number | null;
longitude: number | null;
location: string | null;
is_public: boolean;
collection: string | null;
created_at: string; // ISO 8601 date string
updated_at: string; // ISO 8601 date string
};