Files
voyage/backend/server/adventures/views/collection_view.py
Sean Morley c39ff2449e [v0.12.0 pre] Planning & Itinerary Overhaul, Recommendation Engine, OIDC Enhancements, and More (#931)
* Fixes [REQUEST] Email-based auto-linking for OIDC
Fixes #921

* Add ClusterMap integration for regions and cities with fit-to-bounds functionality

* Update COUNTRY_REGION_JSON_VERSION to v3.0 and modify state ID generation to use ISO2 code

* fix: handle email verification required case during signup

Updated the signup action to return a specific message when the backend responds with a 401 status, indicating that the signup succeeded but email verification is required. This allows the frontend to display the appropriate message using an i18n key.

* feat: add Advanced Configuration documentation with optional environment variables

* Fixes #511

* fix: update appVersion to v0.11.0-main-121425 and enhance socialProviders handling in settings page

* feat: implement social signup controls and update documentation for new environment variables

* fix: update LocationCard props and enhance restore data functionality

- Changed the user prop to null in LocationCard component on the dashboard page.
- Added isRestoring state to manage loading state during data restoration in settings.
- Updated the restore button to show a loading spinner when a restore operation is in progress.

* fix: update appVersion to v0.12.0-pre-dev-121625

* feat: implement itinerary planning feature with CollectionItineraryPlanner component and related updates

* feat: add overnight lodging indicator and functionality to CollectionItineraryPlanner

* feat: add compact display option to LocationCard and enhance lodging filtering in CollectionItineraryPlanner

* feat(itinerary): add itinerary management features and link modal

- Introduced ItineraryViewSet for managing itinerary items with create and reorder functionalities.
- Added itinerary linking capabilities in CollectionModal and CollectionItineraryPlanner components.
- Implemented new ItineraryLinkModal for linking existing items to specific dates.
- Enhanced the frontend with new modals for creating locations, lodging, transportation, notes, and checklists.
- Updated the backend to handle itinerary item creation and reordering with appropriate permissions.
- Improved data handling for unscheduled items and their association with the itinerary.
- Added new dependencies to the frontend for enhanced functionality.

* feat(itinerary): implement auto-generate functionality for itinerary items based on dated records

* feat(collection): enhance collection sharing logic and improve data handling on invite acceptance

* fix: update appVersion to correct pre-dev version

* feat(wikipedia): implement image selection from Wikipedia with enhanced results display

* Refactor code structure for improved readability and maintainability

* feat: add CollectionRecommendationView component for displaying location recommendations

- Implemented CollectionRecommendationView.svelte to handle location recommendations based on user input and selected categories.
- Added Recommendation and RecommendationResponse types to types.ts for better type safety and structure.
- Updated collections/[id]/+page.svelte to include a new view for recommendations, allowing users to switch between different views seamlessly.

* fix: update appVersion and improve button accessibility in collection views

* feat: add canModify prop to collection components for user permission handling

* feat: add itinerary removal functionality to various cards and update UI components

- Implemented `removeFromItinerary` function in `LodgingCard`, `NoteCard`, and `TransportationCard` to allow users to remove items from their itinerary.
- Replaced the trash icon with a calendar remove icon in `LocationCard`, `LodgingCard`, `NoteCard`, and `TransportationCard` for better visual representation.
- Updated the dropdown menus in `LodgingCard`, `NoteCard`, and `TransportationCard` to include the new remove from itinerary option.
- Enhanced `CollectionItineraryPlanner` to pass itinerary items to the respective cards.
- Removed `PointSelectionModal.svelte` as it is no longer needed.
- Refactored `LocationMedia.svelte` to integrate `ImageManagement` component and clean up unused code related to image handling.

* feat: enhance itinerary management with deduplication and initial visit date handling

* feat: add FullMap component for enhanced map functionality with clustering support

- Introduced FullMap.svelte to handle map rendering, clustering, and marker management.
- Updated map page to utilize FullMap component, replacing direct MapLibre usage.
- Implemented clustering options and marker properties handling in FullMap.
- Added utility functions for resolving theme colors and managing marker states.
- Enhanced user experience with hover popups and improved loading states for location details.
- Updated app version to v0.12.0-pre-dev-122225.

* feat: enhance map interaction for touch devices with custom popup handling

* feat: add progress tracker for folder views to display visited and planned locations

* feat: add map center and zoom state management with URL synchronization

* feat: add status and days until start fields to collections with filtering options

* Component folder structure changes

* feat: add LodgingMedia and LodgingModal components for managing lodging details and media attachments

feat: implement LocationSearchMap component for interactive location searching and mapping functionality

* fix: update contentType in ImageManagement component to 'lodging' for correct media handling

* feat: enhance lodging management with date validation and update messages

* feat: implement lodging detail page with server-side loading and image modal functionality

- Added a new server-side load function to fetch lodging details by ID.
- Created a new Svelte component for the lodging detail page, including image carousel and map integration.
- Implemented a modal for displaying images with navigation.
- Enhanced URL handling in the locations page to only read parameters.

* feat: add Transportation modal component and related routes

- Implemented TransportationModal component for creating and editing transportation entries.
- Added server-side loading for transportation details in the new route [id]/+page.server.ts.
- Created a new Svelte page for displaying transportation details with image and attachment handling.
- Integrated modal for editing transportation in the transportation details page.
- Updated lodging routes to include a modal for editing lodging entries.
- Removed unused delete action from lodging server-side logic.

* feat: add start_code and end_code fields to Transportation model and update related components

* feat: implement date validation for itinerary items and add day picker modal for scheduling

* Reorder town and county checks in geocoding.py

Fix detection if only town exists for a location but county is no city name

* Use address keys only if city is found

* Make sure reverse geocoding uses correct key for cities (#938)

* Reorder town and county checks in geocoding.py

Fix detection if only town exists for a location but county is no city name

* Use address keys only if city is found

* Refactor code structure for improved readability and maintainability

* Enhance collection management with modal updates and item handling

* feat: integrate CollectionMap component in collections page and update map titles in lodging and transportation pages

- Replaced inline map implementation with CollectionMap component in collections/[id]/+page.svelte for better modularity.
- Updated the map title in lodging/[id]/+page.svelte to reflect lodging context.
- Updated the map title in transportations/[id]/+page.svelte to reflect transportation context.
- Added functionality to collect and render GeoJSON data from transportation attachments in transportations/[id]/+page.svelte.

* chore: update copyright year to 2026 in various files

* feat: enhance backup export functionality with itinerary items and export IDs

* fix: improve dropdown close behavior by handling multiple event types

* fix: remove unnecessary cache decorator from globespin function

* feat: add initial visit date support in ChecklistModal and NoteModal, with UI suggestions for prefilled dates

* feat: add details view for checklist and note cards with edit functionality

* feat: add travel duration and GPX distance calculation to Transportation model and UI

* feat: add primary image support to Collection model, serializers, and UI components

* Refactor calendar components and enhance event detail handling

- Replaced direct calendar implementation with a reusable CalendarComponent in the calendar route.
- Introduced EventDetailsModal for displaying event details, improving modularity and readability.
- Added functionality to fetch event details asynchronously when an event is clicked.
- Implemented ICS calendar download functionality with loading state management.
- Enhanced collections page to support calendar view, integrating event handling and timezone management.
- Improved lodging and transportation pages to display local time for stays and trips, including timezone badges.
- Cleaned up unused code and comments for better maintainability.

* feat: enhance hero image handling in collection view by prioritizing primary image

* chore: update .env.example to include account email verification configuration

* feat: enhance LodgingCard and TransportationCard components with expandable details and improved layout

* feat: add price and currency fields to locations, lodging, and transportation components

- Introduced price and price_currency fields in LocationModal, LodgingDetails, LodgingModal, TransportationDetails, and TransportationModal components.
- Implemented MoneyInput and CurrencyDropdown components for handling monetary values and currency selection.
- Updated data structures and types to accommodate new price and currency fields across various models.
- Enhanced cost summary calculations in collections and routes to display total costs by currency.
- Added user preference for default currency in settings, affecting new item forms.
- Updated UI to display price information in relevant components, ensuring consistent formatting and user experience.

* feat: add Development Timeline link to overview and create timeline documentation

* feat: enhance map functionality with search and zoom features

- Updated availableViews in collection page to include map view based on lodging and transportation locations.
- Added search functionality to the map page, allowing users to filter pins by name and category.
- Implemented auto-zoom feature to adjust the map view based on filtered search results.
- Introduced a search bar with a clear button for better user experience.

* feat: enhance ISO code extraction and region matching logic in extractIsoCode function

* feat: enhance extractIsoCode function with normalization for locality matching

* feat: update extractIsoCode function to include additional ISO3166 levels for improved region matching

* feat: enhance extractIsoCode function to handle cases without city information and update CollectionMap to bind user data

* feat: add cron job for syncing visited regions and cities, enhance Docker and supervisord configurations

* feat: add CollectionItineraryDay model and related functionality for itinerary day metadata management

* feat: implement cleanup of out-of-range itinerary items and notify users of potential impacts on itinerary when dates change

* Refactor collection page for improved localization and code clarity

- Removed unused imports and consolidated cost category labels to be reactive.
- Updated cost summary function to accept localized labels.
- Enhanced localization for various UI elements, including buttons, headings, and statistics.
- Improved user feedback messages for better clarity and consistency.
- Ensured all relevant text is translatable using the i18n library.

* feat: add collaborator serialization and display in collections

- Implemented `_build_profile_pic_url` and `_serialize_collaborator` functions for user profile picture URLs and serialization.
- Updated `CollectionSerializer` and `UltraSlimCollectionSerializer` to include collaborators in the serialized output.
- Enhanced `CollectionViewSet` to prefetch shared_with users for optimized queries.
- Modified frontend components to display collaborators in collection details, including profile pictures and initials.
- Added new localization strings for collaborators.
- Refactored map and location components to improve usability and functionality.
- Updated app version to reflect new changes.

* feat: add dynamic lodging icons based on type in CollectionMap component

* feat: add CollectionStats component for detailed trip statistics

- Implemented CollectionStats.svelte to display various statistics related to the collection, including distances, activities, and locations visited.
- Enhanced CollectionMap.svelte to filter activities based on date range using new getActivityDate function.
- Updated LocationSearchMap.svelte to handle airport mode for start and end locations.
- Modified types.ts to include is_global property in CollectionItineraryItem for trip-wide items.
- Updated +page.svelte to integrate the new stats view and manage view state accordingly.

* feat: enhance itinerary management by removing old items on date change for notes and checklists; normalize date handling in CollectionMap

* feat: add functionality to change day and move items to trip-wide itinerary

- Implemented changeDay function in ChecklistCard, LocationCard, LodgingCard, NoteCard, and TransportationCard components to allow users to change the scheduled day of items.
- Added a button to move items to the global (trip-wide) itinerary in the aforementioned components, with appropriate dispatch events.
- Enhanced CollectionItineraryPlanner to handle moving items to the global itinerary and added UI elements for unscheduled items.
- Updated ItineraryDayPickModal to support the deletion of source visits when moving locations.
- Added new translations for "Change Day" and "Move Trip Wide" in the English locale.

* fix: specify full path for python3 in cron job and add shell and path variables

* fix: update appVersion to v0.12.0-pre-dev-010726

* feat: enhance CollectionItineraryPlanner and CollectionStats with dynamic links and transport type normalization

* Add Dev Container + WSL install docs and link in install guide (#944) (#951)

* feat: enhance internationalization support in CollectionMap and CollectionStats components

- Added translation support for various labels and messages in CollectionMap.svelte and CollectionStats.svelte using svelte-i18n.
- Updated English and Chinese locale files to include new translation keys for improved user experience.
- Simplified the rendering of recommendation views in the collections page.

* Refactor itinerary management and UI components

- Updated ItineraryViewSet to handle visit updates and creations more efficiently, preserving visit IDs when moving between days.
- Enhanced ChecklistCard, LodgingCard, TransportationCard, and NoteCard to include a new "Change Day" option in the actions menu.
- Improved user experience in CollectionItineraryPlanner by tracking specific itinerary items being moved and ensuring only the relevant entries are deleted.
- Added new location sharing options in LodgingCard and TransportationCard for Apple Maps, Google Maps, and OpenStreetMap.
- Updated translations in en.json for consistency and clarity.
- Minor UI adjustments for better accessibility and usability across various components.

* feat: implement action menus and close event handling in card components

* feat: refactor Dockerfile and supervisord configuration to remove cron and add periodic sync script

* feat: enhance LocationSearchMap and TransportationDetails components with initialization handling and airport mode logic

* feat: add airport and location search mode labels to localization file

* feat: enhance periodic sync logging and improve airport mode handling in LocationSearchMap

* feat: enhance unscheduled items display with improved card interactions and accessibility

* Add dev compose for hot reload and update WSL dev container docs (#958)

* feat: enhance localization for itinerary linking and transportation components

* Localization: update localization files with new keys and values

* fix: improve error messages for Overpass API responses

* chore: update dependencies in frontend package.json and pnpm-lock.yaml

- Updated @sveltejs/adapter-node from ^5.2.12 to ^5.4.0
- Updated @sveltejs/adapter-vercel from ^5.7.0 to ^6.3.0
- Updated tailwindcss from ^3.4.17 to ^3.4.19
- Updated typescript from ^5.8.3 to ^5.9.3
- Updated vite from ^5.4.19 to ^5.4.21

* chore: update dependencies in pnpm-lock.yaml to latest versions

* Refactor code structure for improved readability and maintainability

* Refactor code structure for improved readability and maintainability

* fix: update package dependencies to resolve compatibility issues

* Add "worldtravel" translations to multiple locale files

- Added "worldtravel" key with translations for Spanish, French, Hungarian, Italian, Japanese, Korean, Dutch, Norwegian, Polish, Brazilian Portuguese, Russian, Slovak, Swedish, Turkish, Ukrainian, and Chinese.
- Updated the navigation section in each locale file to include the new "worldtravel" entry.

* Add new screenshots and update email verification message in locale file

* feat: Implement data restoration functionality with file import

- Added a new action `restoreData` in `+page.server.ts` to handle file uploads for restoring collections.
- Enhanced the UI in `+page.svelte` to include an import button and a modal for import progress.
- Integrated file input handling to trigger form submission upon file selection.
- Removed unused GSAP animations from the login, profile, and signup pages for cleaner code.

* feat: Add modals for creating locations and lodging from recommendations, enhance image import functionality

* fix: Adjust styles to prevent horizontal scroll and enhance floating action button visibility

* feat: Enhance error handling and messaging for Google Maps and OpenStreetMap geocoding functions

* fix: Enhance error messaging for Google Maps access forbidden response

* feat: Add User-Agent header to Google Maps API requests and refine error messaging for access forbidden response

* fix: Update User-Agent header in Google Maps API requests for improved compatibility

* fix: Disable proxy settings in Google Maps API request to prevent connection issues

* fix: Update Trivy security scan configuration and add .trivyignore for known false positives

* fix: Refactor update method to handle is_public cascading for related items

* feat: Integrate django-invitations for user invitation management and update settings

* feat: Add Tailwind CSS and DaisyUI plugin for styling

* feat: Add Tailwind CSS and DaisyUI plugin for styling

* feat: Add "Invite a User" guide and update navigation links

* docs: Update "Invite a User" guide to include email configuration tip

* feat: Update email invitation template for improved styling and clarity

* fix: Remove trailing backslash from installation note in Unraid documentation

* feat: Add export/import messages and user email verification prompts in multiple languages

* Squashed commit of the following:

commit a993a15b93ebb7521ae2e5cc31596b98b29fcd6c
Author: Alex <div@alexe.at>
Date:   Mon Jan 12 20:44:47 2026 +0100

    Translated using Weblate (German)

    Currently translated at 100.0% (1048 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

commit fdc455d9424fbb0f6b72179d9eb1340411700773
Author: Ettore Atalan <atalanttore@googlemail.com>
Date:   Sat Jan 10 23:24:23 2026 +0100

    Translated using Weblate (German)

    Currently translated at 100.0% (1048 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

commit 5942129c55e89dd999a13d4df9c40e6e3189355c
Author: Orhun <orhunavcu@gmail.com>
Date:   Sun Jan 11 13:05:31 2026 +0100

    Translated using Weblate (Turkish)

    Currently translated at 100.0% (1048 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/tr/

commit 8712e43d8ba4a7e7fe163fb454d6577187f9a375
Author: Henrique Fonseca Veloso <henriquefv@tutamail.com>
Date:   Fri Jan 9 22:53:11 2026 +0100

    Translated using Weblate (Portuguese (Brazil))

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/pt_BR/

commit 18ee56653470413afe8d71ecd2b5028f6e4cf118
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:52:57 2026 +0100

    Translated using Weblate (Dutch)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/nl/

commit 57783c544e583c035c8b57b5c10ca320f25f399e
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:52:14 2026 +0100

    Translated using Weblate (Arabic)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/ar/

commit fb09edfd85bc85234b1c1ba7dd499f2915093fff
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:52:26 2026 +0100

    Translated using Weblate (Spanish)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

commit 554a207d8e454a1f7ae826e2a40d389b94be5512
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:52:21 2026 +0100

    Translated using Weblate (German)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

commit b70b9db27fb8607beefeb288185601c8f5eae28d
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:53:02 2026 +0100

    Translated using Weblate (Norwegian Bokmål)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/nb_NO/

commit 3b467caa9007c553e4ae7de97f53b6e462161ea3
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:53:07 2026 +0100

    Translated using Weblate (Polish)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/pl/

commit 30fbbfba3572c8f78ec7c7e1a231e363aca1ef10
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:53:17 2026 +0100

    Translated using Weblate (Russian)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/ru/

commit 8cecb492cfcac0a1f93ee8919f7b41d978d331ee
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:52:42 2026 +0100

    Translated using Weblate (Italian)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/it/

commit f0d3d41029c89bfa83d5891ee7af70241f27b7be
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:52:38 2026 +0100

    Translated using Weblate (Hungarian)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/hu/

commit 102e0f1912d010d38755a1713abb2a7f7564aafb
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:53:21 2026 +0100

    Translated using Weblate (Slovak)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sk/

commit 428b8f18cf6195a96b55109e0221413d82415a2f
Author: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Date:   Sat Jan 10 08:55:28 2026 +0100

    Translated using Weblate (Ukrainian)

    Currently translated at 100.0% (1048 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/uk/

commit 1a71aaf279ecab26c0c1fede05025732e6dcfa5e
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:53:27 2026 +0100

    Translated using Weblate (Swedish)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sv/

commit 36ec3701f3a1a904e7c42ac4ffbe6a050dc6d1ed
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:53:43 2026 +0100

    Translated using Weblate (Chinese (Simplified Han script))

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/zh_Hans/

commit 65d8b74b340c877cad2028b7142c783a1b568d49
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:52:48 2026 +0100

    Translated using Weblate (Japanese)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/ja/

commit 4d11d1d31022583657e93aee70301a8ffcde1340
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:52:52 2026 +0100

    Translated using Weblate (Korean)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/ko/

commit bd1135bcb965ad73cf493771b15081cc97cf513a
Author: Orhun <orhunavcu@gmail.com>
Date:   Fri Jan 9 22:53:33 2026 +0100

    Translated using Weblate (Turkish)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/tr/

commit 2c3d814119f4cf2dabd20933699f5b991f20f3e6
Author: Anonymous <noreply@weblate.org>
Date:   Fri Jan 9 22:52:32 2026 +0100

    Translated using Weblate (French)

    Currently translated at 99.9% (1047 of 1048 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/fr/

* Refactor code structure and remove redundant code blocks for improved readability and maintainability

* fix: Correct appVersion to match the latest pre-release version

* fix: Add missing vulnerability reference for jaraco.context in .trivyignore

---------

Co-authored-by: Lars Lehmann <33843261+larsl-net@users.noreply.github.com>
Co-authored-by: Lars Lehmann <lars@lmail.eu>
Co-authored-by: Nick Petrushin <n.a.petrushin@gmail.com>
2026-01-14 10:22:14 -05:00

911 lines
41 KiB
Python

from django.db.models import Q, Prefetch
from django.db.models.functions import Lower
from django.db import transaction
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser
from rest_framework import status
from django.http import HttpResponse
from django.conf import settings
import io
import os
import json
import zipfile
import tempfile
from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite, ContentImage, CollectionItineraryItem, Lodging, CollectionItineraryDay, ContentAttachment, Category
from adventures.permissions import CollectionShared
from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer, CollectionItineraryItemSerializer, CollectionItineraryDaySerializer
from users.models import CustomUser as User
from adventures.utils import pagination
from users.serializers import CustomUserDetailsSerializer as UserSerializer
class CollectionViewSet(viewsets.ModelViewSet):
serializer_class = CollectionSerializer
permission_classes = [CollectionShared]
pagination_class = pagination.StandardResultsSetPagination
def get_serializer_class(self):
"""Return different serializers based on the action"""
if self.action in ['list', 'all', 'archived', 'shared']:
return UltraSlimCollectionSerializer
return CollectionSerializer
def apply_sorting(self, queryset):
order_by = self.request.query_params.get('order_by', 'name')
order_direction = self.request.query_params.get('order_direction', 'asc')
valid_order_by = ['name', 'updated_at', 'start_date']
if order_by not in valid_order_by:
order_by = 'updated_at'
if order_direction not in ['asc', 'desc']:
order_direction = 'asc'
# Apply case-insensitive sorting for the 'name' field
if order_by == 'name':
queryset = queryset.annotate(lower_name=Lower('name'))
ordering = 'lower_name'
if order_direction == 'asc':
ordering = f'-{ordering}'
elif order_by == 'start_date':
ordering = 'start_date'
if order_direction == 'desc':
ordering = 'start_date'
else:
ordering = '-start_date'
else:
order_by == 'updated_at'
ordering = 'updated_at'
if order_direction == 'desc':
ordering = '-updated_at'
return queryset.order_by(ordering)
def apply_status_filter(self, queryset):
"""Apply status filtering based on query parameter"""
from datetime import date
status_filter = self.request.query_params.get('status', None)
if not status_filter:
return queryset
today = date.today()
if status_filter == 'folder':
# Collections without dates
return queryset.filter(Q(start_date__isnull=True) | Q(end_date__isnull=True))
elif status_filter == 'upcoming':
# Start date in the future
return queryset.filter(start_date__gt=today)
elif status_filter == 'in_progress':
# Currently ongoing
return queryset.filter(start_date__lte=today, end_date__gte=today)
elif status_filter == 'completed':
# End date in the past
return queryset.filter(end_date__lt=today)
return queryset
def get_serializer_context(self):
"""Override to add nested and exclusion contexts based on query parameters"""
context = super().get_serializer_context()
# Handle nested parameter (only for full serializer actions)
if self.action not in ['list', 'all', 'archived', 'shared']:
is_nested = self.request.query_params.get('nested', 'false').lower() == 'true'
if is_nested:
context['nested'] = True
# Handle individual exclusion parameters (if using granular approach)
exclude_params = [
'exclude_transportations',
'exclude_notes',
'exclude_checklists',
'exclude_lodging'
]
for param in exclude_params:
if self.request.query_params.get(param, 'false').lower() == 'true':
context[param] = True
return context
def get_optimized_queryset_for_listing(self):
"""Get optimized queryset for list actions with prefetching"""
return self.get_base_queryset().select_related('user', 'primary_image').prefetch_related(
Prefetch(
'locations__images',
queryset=ContentImage.objects.filter(is_primary=True).select_related('user'),
to_attr='primary_images'
),
'shared_with'
)
def get_base_queryset(self):
"""Base queryset logic extracted for reuse"""
if self.action == 'destroy':
queryset = Collection.objects.filter(user=self.request.user.id)
elif self.action in ['update', 'partial_update', 'leave']:
queryset = Collection.objects.filter(
Q(user=self.request.user.id) | Q(shared_with=self.request.user)
).distinct()
# Allow access to collections with pending invites for accept/decline actions
elif self.action in ['accept_invite', 'decline_invite']:
if not self.request.user.is_authenticated:
queryset = Collection.objects.none()
else:
queryset = Collection.objects.filter(
Q(user=self.request.user.id)
| Q(shared_with=self.request.user)
| Q(invites__invited_user=self.request.user)
).distinct()
elif self.action == 'retrieve':
if not self.request.user.is_authenticated:
queryset = Collection.objects.filter(is_public=True)
else:
queryset = Collection.objects.filter(
Q(is_public=True)
| Q(user=self.request.user.id)
| Q(shared_with=self.request.user)
).distinct()
else:
# For list action and default base queryset, return collections owned by the user (exclude shared)
queryset = Collection.objects.filter(
Q(user=self.request.user.id) & Q(is_archived=False)
).distinct()
return queryset.select_related('primary_image').prefetch_related('shared_with')
def get_queryset(self):
"""Get queryset with optimizations for list actions"""
if self.action in ['list', 'all', 'archived', 'shared']:
return self.get_optimized_queryset_for_listing()
return self.get_base_queryset()
def list(self, request):
# make sure the user is authenticated
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
# List should only return collections owned by the requesting user (shared collections are available
# via the `shared` action).
queryset = Collection.objects.filter(
Q(user=request.user.id) & Q(is_archived=False)
).distinct().select_related('user', 'primary_image').prefetch_related(
Prefetch(
'locations__images',
queryset=ContentImage.objects.filter(is_primary=True).select_related('user'),
to_attr='primary_images'
)
)
queryset = self.apply_status_filter(queryset)
queryset = self.apply_sorting(queryset)
return self.paginate_and_respond(queryset, request)
@action(detail=False, methods=['get'])
def all(self, request):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = Collection.objects.filter(
Q(user=request.user)
).select_related('user', 'primary_image').prefetch_related(
Prefetch(
'locations__images',
queryset=ContentImage.objects.filter(is_primary=True).select_related('user'),
to_attr='primary_images'
)
)
queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def archived(self, request):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = Collection.objects.filter(
Q(user=request.user.id) & Q(is_archived=True)
).select_related('user', 'primary_image').prefetch_related(
Prefetch(
'locations__images',
queryset=ContentImage.objects.filter(is_primary=True).select_related('user'),
to_attr='primary_images'
)
)
queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def retrieve(self, request, pk=None):
"""Retrieve a collection and include itinerary items and day metadata in the response."""
collection = self.get_object()
serializer = self.get_serializer(collection)
data = serializer.data
# Include itinerary items inline with collection details
itinerary_items = CollectionItineraryItem.objects.filter(collection=collection)
itinerary_serializer = CollectionItineraryItemSerializer(itinerary_items, many=True)
data['itinerary'] = itinerary_serializer.data
# Include itinerary day metadata
itinerary_days = CollectionItineraryDay.objects.filter(collection=collection)
days_serializer = CollectionItineraryDaySerializer(itinerary_days, many=True)
data['itinerary_days'] = days_serializer.data
return Response(data)
# make an action to retreive all locations that are shared with the user
@action(detail=False, methods=['get'])
def shared(self, request):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = Collection.objects.filter(
shared_with=request.user
).select_related('user').prefetch_related(
Prefetch(
'locations__images',
queryset=ContentImage.objects.filter(is_primary=True).select_related('user'),
to_attr='primary_images'
)
)
queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
# Created a custom action to share a collection with another user by their UUID
# This action will create a CollectionInvite instead of directly sharing the collection
@action(detail=True, methods=['post'], url_path='share/(?P<uuid>[^/.]+)')
def share(self, request, pk=None, uuid=None):
collection = self.get_object()
if not uuid:
return Response({"error": "User UUID is required"}, status=400)
try:
user = User.objects.get(uuid=uuid, public_profile=True)
except User.DoesNotExist:
return Response({"error": "User not found"}, status=404)
if user == request.user:
return Response({"error": "Cannot share with yourself"}, status=400)
# Check if user is already shared with the collection
if collection.shared_with.filter(id=user.id).exists():
return Response({"error": "Collection is already shared with this user"}, status=400)
# Check if there's already a pending invite for this user
if CollectionInvite.objects.filter(collection=collection, invited_user=user).exists():
return Response({"error": "Invite already sent to this user"}, status=400)
# Create the invite instead of directly sharing
invite = CollectionInvite.objects.create(
collection=collection,
invited_user=user
)
return Response({"success": f"Invite sent to {user.username}"})
# Custom action to list all invites for a user
@action(detail=False, methods=['get'], url_path='invites')
def invites(self, request):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
invites = CollectionInvite.objects.filter(invited_user=request.user)
serializer = CollectionInviteSerializer(invites, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='revoke-invite/(?P<uuid>[^/.]+)')
def revoke_invite(self, request, pk=None, uuid=None):
"""Revoke a pending invite for a collection"""
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
if not uuid:
return Response({"error": "User UUID is required"}, status=400)
try:
user = User.objects.get(uuid=uuid, public_profile=True)
except User.DoesNotExist:
return Response({"error": "User not found"}, status=404)
# Only collection owner can revoke invites
if collection.user != request.user:
return Response({"error": "Only collection owner can revoke invites"}, status=403)
try:
invite = CollectionInvite.objects.get(collection=collection, invited_user=user)
invite.delete()
return Response({"success": f"Invite revoked for {user.username}"})
except CollectionInvite.DoesNotExist:
return Response({"error": "No pending invite found for this user"}, status=404)
@action(detail=True, methods=['post'], url_path='accept-invite')
def accept_invite(self, request, pk=None):
"""Accept a collection invite"""
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
try:
invite = CollectionInvite.objects.get(collection=collection, invited_user=request.user)
except CollectionInvite.DoesNotExist:
return Response({"error": "No pending invite found for this collection"}, status=404)
# Add user to collection's shared_with
collection.shared_with.add(request.user)
# Delete the invite
invite.delete()
return Response({"success": f"Successfully joined collection: {collection.name}"})
@action(detail=True, methods=['post'], url_path='decline-invite')
def decline_invite(self, request, pk=None):
"""Decline a collection invite"""
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
try:
invite = CollectionInvite.objects.get(collection=collection, invited_user=request.user)
invite.delete()
return Response({"success": f"Declined invite for collection: {collection.name}"})
except CollectionInvite.DoesNotExist:
return Response({"error": "No pending invite found for this collection"}, status=404)
# Action to list all users a collection **can** be shared with, excluding those already shared with and those with pending invites
@action(detail=True, methods=['get'], url_path='can-share')
def can_share(self, request, pk=None):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
# Get users with pending invites and users already shared with
users_with_pending_invites = set(str(uuid) for uuid in CollectionInvite.objects.filter(collection=collection).values_list('invited_user__uuid', flat=True))
users_already_shared = set(str(uuid) for uuid in collection.shared_with.values_list('uuid', flat=True))
# Get all users with public profiles excluding only the owner
all_users = User.objects.filter(public_profile=True).exclude(id=request.user.id)
# Return fully serialized user data with status
serializer = UserSerializer(all_users, many=True)
result_data = []
for user_data in serializer.data:
user_data.pop('has_password', None)
user_data.pop('disable_password', None)
# Add status field
if user_data['uuid'] in users_with_pending_invites:
user_data['status'] = 'pending'
elif user_data['uuid'] in users_already_shared:
user_data['status'] = 'shared'
else:
user_data['status'] = 'available'
result_data.append(user_data)
return Response(result_data)
@action(detail=True, methods=['post'], url_path='unshare/(?P<uuid>[^/.]+)')
def unshare(self, request, pk=None, uuid=None):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
if not uuid:
return Response({"error": "User UUID is required"}, status=400)
try:
user = User.objects.get(uuid=uuid, public_profile=True)
except User.DoesNotExist:
return Response({"error": "User not found"}, status=404)
if user == request.user:
return Response({"error": "Cannot unshare with yourself"}, status=400)
if not collection.shared_with.filter(id=user.id).exists():
return Response({"error": "Collection is not shared with this user"}, status=400)
# Remove user from shared_with
collection.shared_with.remove(user)
# Handle locations owned by the unshared user that are in this collection
# These locations should be removed from the collection since they lose access
locations_to_remove = collection.locations.filter(user=user)
removed_count = locations_to_remove.count()
if locations_to_remove.exists():
# Remove these locations from the collection
collection.locations.remove(*locations_to_remove)
collection.save()
success_message = f"Unshared with {user.username}"
if removed_count > 0:
success_message += f" and removed {removed_count} location(s) they owned from the collection"
return Response({"success": success_message})
# Action for a shared user to leave a collection
@action(detail=True, methods=['post'], url_path='leave')
def leave(self, request, pk=None):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
collection = self.get_object()
if request.user == collection.user:
return Response({"error": "Owner cannot leave their own collection"}, status=400)
if not collection.shared_with.filter(id=request.user.id).exists():
return Response({"error": "You are not a member of this collection"}, status=400)
# Remove the user from shared_with
collection.shared_with.remove(request.user)
# Handle locations owned by the user that are in this collection
locations_to_remove = collection.locations.filter(user=request.user)
removed_count = locations_to_remove.count()
if locations_to_remove.exists():
# Remove these locations from the collection
collection.locations.remove(*locations_to_remove)
collection.save()
success_message = f"You have left the collection: {collection.name}"
if removed_count > 0:
success_message += f" and removed {removed_count} location(s) you owned from the collection"
return Response({"success": success_message})
@action(detail=True, methods=['get'], url_path='export')
def export_collection(self, request, pk=None):
"""Export a single collection and its related content as a ZIP file."""
collection = self.get_object()
export_data = {
'version': getattr(settings, 'ADVENTURELOG_RELEASE_VERSION', 'unknown'),
# Omit export_date to keep template-friendly exports (no dates)
'collection': {
'id': str(collection.id),
'name': collection.name,
'description': collection.description,
'is_public': collection.is_public,
# Omit start/end dates
'link': collection.link,
},
'locations': [],
'transportation': [],
'notes': [],
'checklists': [],
'lodging': [],
# Omit itinerary_items entirely
'images': [],
'attachments': [],
'primary_image_ref': None,
}
image_export_map = {}
for loc in collection.locations.all().select_related('city', 'region', 'country'):
loc_entry = {
'id': str(loc.id),
'name': loc.name,
'description': loc.description,
'location': loc.location,
'tags': loc.tags or [],
'rating': loc.rating,
'link': loc.link,
'is_public': loc.is_public,
'longitude': float(loc.longitude) if loc.longitude is not None else None,
'latitude': float(loc.latitude) if loc.latitude is not None else None,
'city': loc.city.name if loc.city else None,
'region': loc.region.name if loc.region else None,
'country': loc.country.name if loc.country else None,
'images': [],
'attachments': [],
}
for img in loc.images.all():
img_export_id = f"img_{len(export_data['images'])}"
image_export_map[str(img.id)] = img_export_id
export_data['images'].append({
'export_id': img_export_id,
'id': str(img.id),
'name': os.path.basename(getattr(img.image, 'name', 'image')),
'is_primary': getattr(img, 'is_primary', False),
})
loc_entry['images'].append(img_export_id)
for att in loc.attachments.all():
att_export_id = f"att_{len(export_data['attachments'])}"
export_data['attachments'].append({
'export_id': att_export_id,
'id': str(att.id),
'name': os.path.basename(getattr(att.file, 'name', 'attachment')),
})
loc_entry['attachments'].append(att_export_id)
export_data['locations'].append(loc_entry)
if collection.primary_image:
export_data['primary_image_ref'] = image_export_map.get(str(collection.primary_image.id))
# Related content (if models have FK to collection)
for t in Transportation.objects.filter(collection=collection):
export_data['transportation'].append({
'id': str(t.id),
'type': getattr(t, 'transportation_type', None),
'name': getattr(t, 'name', None),
# Omit date
'notes': getattr(t, 'notes', None),
})
for n in Note.objects.filter(collection=collection):
export_data['notes'].append({
'id': str(n.id),
'title': getattr(n, 'title', None),
'content': getattr(n, 'content', ''),
# Omit created_at
})
for c in Checklist.objects.filter(collection=collection):
items = []
if hasattr(c, 'items'):
items = [
{
'name': getattr(item, 'name', None),
'completed': getattr(item, 'completed', False),
} for item in c.items.all()
]
export_data['checklists'].append({
'id': str(c.id),
'name': getattr(c, 'name', None),
'items': items,
})
for l in Lodging.objects.filter(collection=collection):
export_data['lodging'].append({
'id': str(l.id),
'type': getattr(l, 'lodging_type', None),
'name': getattr(l, 'name', None),
# Omit start_date/end_date
'notes': getattr(l, 'notes', None),
})
# Intentionally omit itinerary_items from export
# Create ZIP in temp file
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp_file:
with zipfile.ZipFile(tmp_file, 'w', zipfile.ZIP_DEFLATED) as zipf:
zipf.writestr('metadata.json', json.dumps(export_data, indent=2))
# Write image files
for loc in collection.locations.all():
for img in loc.images.all():
export_id = image_export_map.get(str(img.id))
if not export_id:
continue
try:
file_name = os.path.basename(getattr(img.image, 'name', 'image'))
storage = getattr(img.image, 'storage', None)
if storage:
with storage.open(img.image.name, 'rb') as f:
zipf.writestr(f'images/{export_id}-{file_name}', f.read())
elif hasattr(img.image, 'path'):
with open(img.image.path, 'rb') as f:
zipf.writestr(f'images/{export_id}-{file_name}', f.read())
except Exception:
continue
# Write attachment files
for loc in collection.locations.all():
for att in loc.attachments.all():
try:
file_name = os.path.basename(getattr(att.file, 'name', 'attachment'))
storage = getattr(att.file, 'storage', None)
if storage:
with storage.open(att.file.name, 'rb') as f:
zipf.writestr(f'attachments/{file_name}', f.read())
elif hasattr(att.file, 'path'):
with open(att.file.path, 'rb') as f:
zipf.writestr(f'attachments/{file_name}', f.read())
except Exception:
continue
with open(tmp_file.name, 'rb') as fh:
data = fh.read()
os.unlink(tmp_file.name)
filename = f"collection-{collection.name.replace(' ', '_')}.zip"
response = HttpResponse(data, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@action(detail=False, methods=['post'], url_path='import', parser_classes=[MultiPartParser])
def import_collection(self, request):
"""Import a single collection from a ZIP file. Handles name conflicts by appending (n)."""
upload = request.FILES.get('file')
if not upload:
return Response({'detail': 'No file provided'}, status=status.HTTP_400_BAD_REQUEST)
# Read zip
file_bytes = upload.read()
with zipfile.ZipFile(io.BytesIO(file_bytes), 'r') as zipf:
try:
metadata = json.loads(zipf.read('metadata.json').decode('utf-8'))
except KeyError:
return Response({'detail': 'metadata.json missing'}, status=status.HTTP_400_BAD_REQUEST)
base_name = (metadata.get('collection') or {}).get('name') or 'Imported Collection'
# Ensure unique name per user
existing_names = set(request.user.collection_set.values_list('name', flat=True))
unique_name = base_name
if unique_name in existing_names:
i = 1
while True:
candidate = f"{base_name} ({i})"
if candidate not in existing_names:
unique_name = candidate
break
i += 1
new_collection = Collection.objects.create(
user=request.user,
name=unique_name,
description=(metadata.get('collection') or {}).get('description'),
is_public=(metadata.get('collection') or {}).get('is_public', False),
start_date=__import__('datetime').date.fromisoformat((metadata.get('collection') or {}).get('start_date')) if (metadata.get('collection') or {}).get('start_date') else None,
end_date=__import__('datetime').date.fromisoformat((metadata.get('collection') or {}).get('end_date')) if (metadata.get('collection') or {}).get('end_date') else None,
link=(metadata.get('collection') or {}).get('link'),
)
image_export_map = {img['export_id']: img for img in metadata.get('images', [])}
attachment_export_map = {att['export_id']: att for att in metadata.get('attachments', [])}
# Import locations
for loc_data in metadata.get('locations', []):
cat_obj = None
if loc_data.get('category'):
cat_obj, _ = Category.objects.get_or_create(user=request.user, name=loc_data['category'])
# Attempt to find a very similar existing location for this user
from difflib import SequenceMatcher
def _ratio(a, b):
a = (a or '').strip().lower()
b = (b or '').strip().lower()
if not a and not b:
return 1.0
return SequenceMatcher(None, a, b).ratio()
def _coords_close(lat1, lon1, lat2, lon2, threshold=0.02):
try:
if lat1 is None or lon1 is None or lat2 is None or lon2 is None:
return False
return abs(float(lat1) - float(lat2)) <= threshold and abs(float(lon1) - float(lon2)) <= threshold
except Exception:
return False
incoming_name = loc_data.get('name') or 'Untitled'
incoming_location_text = loc_data.get('location')
incoming_lat = loc_data.get('latitude')
incoming_lon = loc_data.get('longitude')
existing_loc = None
best_score = 0.0
for cand in Location.objects.filter(user=request.user):
name_score = _ratio(incoming_name, cand.name)
loc_text_score = _ratio(incoming_location_text, getattr(cand, 'location', None))
close_coords = _coords_close(incoming_lat, incoming_lon, cand.latitude, cand.longitude)
# Define "very similar": strong name match OR decent name with location/coords match
combined_score = max(name_score, (name_score + loc_text_score) / 2.0)
if close_coords:
combined_score = max(combined_score, name_score + 0.1) # small boost for coord proximity
if combined_score > best_score and (
name_score >= 0.92 or (name_score >= 0.85 and (loc_text_score >= 0.85 or close_coords))
):
best_score = combined_score
existing_loc = cand
if existing_loc:
# Link existing location to the new collection, skip creating a duplicate
loc = existing_loc
loc.collections.add(new_collection)
created_new_loc = False
else:
# Create a brand-new location
loc = Location.objects.create(
user=request.user,
name=incoming_name,
description=loc_data.get('description'),
location=incoming_location_text,
tags=loc_data.get('tags') or [],
rating=loc_data.get('rating'),
link=loc_data.get('link'),
is_public=bool(loc_data.get('is_public', False)),
longitude=incoming_lon,
latitude=incoming_lat,
category=cat_obj,
)
loc.collections.add(new_collection)
created_new_loc = True
# Images
# Only import images for newly created locations to avoid duplicating user content
if created_new_loc:
for export_id in loc_data.get('images', []):
img_meta = image_export_map.get(export_id)
if not img_meta:
continue
prefix = f"images/{export_id}-"
member = next((m for m in zipf.namelist() if m.startswith(prefix)), None)
if not member:
continue
file_bytes_img = zipf.read(member)
file_name_img = os.path.basename(member)
from django.core.files.base import ContentFile
image_obj = ContentImage(
user=request.user,
image=ContentFile(file_bytes_img, name=file_name_img),
)
# Assign to the generic relation for Location
image_obj.content_object = loc
image_obj.save()
if img_meta.get('is_primary'):
new_collection.primary_image = image_obj
new_collection.save(update_fields=['primary_image'])
# Attachments
if created_new_loc:
for export_id in loc_data.get('attachments', []):
att_meta = attachment_export_map.get(export_id)
if not att_meta:
continue
file_name_att = att_meta.get('name', '')
member = next((m for m in zipf.namelist() if m == f"attachments/{file_name_att}"), None)
if not member:
continue
file_bytes_att = zipf.read(member)
from django.core.files.base import ContentFile
attachment_obj = ContentAttachment(
user=request.user,
file=ContentFile(file_bytes_att, name=file_name_att),
)
# Assign to the generic relation for Location
attachment_obj.content_object = loc
attachment_obj.save()
serializer = self.get_serializer(new_collection)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def perform_create(self, serializer):
# This is ok because you cannot share a collection when creating it
serializer.save(user=self.request.user)
def _cleanup_out_of_range_itinerary_items(self, collection):
"""Delete itinerary items and day metadata outside the collection's date range."""
if not collection.start_date or not collection.end_date:
# If no date range is set, don't delete anything
return
# Delete itinerary items outside the date range
deleted_items = CollectionItineraryItem.objects.filter(
collection=collection
).exclude(
date__range=[collection.start_date, collection.end_date]
).delete()
# Delete day metadata outside the date range
deleted_days = CollectionItineraryDay.objects.filter(
collection=collection
).exclude(
date__range=[collection.start_date, collection.end_date]
).delete()
return deleted_items, deleted_days
@transaction.atomic
def update(self, request, *args, **kwargs):
"""Override update to handle is_public cascading and clean up out-of-range itinerary items when dates change."""
instance = self.get_object()
old_is_public = instance.is_public
old_start_date = instance.start_date
old_end_date = instance.end_date
# Perform the standard update
partial = kwargs.pop('partial', False)
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
# Check if is_public changed
new_is_public = serializer.instance.is_public
is_public_changed = old_is_public != new_is_public
# Handle is_public cascading
if is_public_changed:
if new_is_public:
# Collection is being made public, update all linked items to public
serializer.instance.locations.filter(is_public=False).update(is_public=True)
serializer.instance.transportation_set.filter(is_public=False).update(is_public=True)
serializer.instance.note_set.filter(is_public=False).update(is_public=True)
serializer.instance.checklist_set.filter(is_public=False).update(is_public=True)
serializer.instance.lodging_set.filter(is_public=False).update(is_public=True)
else:
# Collection is being made private, check each linked item
# Only set an item to private if it doesn't belong to any other public collection
# Handle locations (many-to-many relationship)
locations_in_collection = serializer.instance.locations.filter(is_public=True)
for location in locations_in_collection:
# Check if this location belongs to any other public collection
has_other_public_collection = location.collections.filter(
is_public=True
).exclude(id=serializer.instance.id).exists()
if not has_other_public_collection:
location.is_public = False
location.save(update_fields=['is_public'])
# Handle transportations, notes, checklists, lodging (foreign key relationships)
# Transportation
transportations_to_check = serializer.instance.transportation_set.filter(is_public=True)
for transportation in transportations_to_check:
transportation.is_public = False
transportation.save(update_fields=['is_public'])
# Notes
notes_to_check = serializer.instance.note_set.filter(is_public=True)
for note in notes_to_check:
note.is_public = False
note.save(update_fields=['is_public'])
# Checklists
checklists_to_check = serializer.instance.checklist_set.filter(is_public=True)
for checklist in checklists_to_check:
checklist.is_public = False
checklist.save(update_fields=['is_public'])
# Lodging
lodging_to_check = serializer.instance.lodging_set.filter(is_public=True)
for lodging in lodging_to_check:
lodging.is_public = False
lodging.save(update_fields=['is_public'])
# Check if dates changed
new_start_date = serializer.instance.start_date
new_end_date = serializer.instance.end_date
dates_changed = (old_start_date != new_start_date or old_end_date != new_end_date)
# Clean up out-of-range items if dates changed
if dates_changed:
self._cleanup_out_of_range_itinerary_items(serializer.instance)
if getattr(instance, '_prefetched_objects_cache', None):
# If 'prefetch_related' has been applied to a queryset, we need to
# forcibly invalidate the prefetch cache on the instance.
instance._prefetched_objects_cache = {}
return Response(serializer.data)
def paginate_and_respond(self, queryset, request):
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request)
if page is not None:
serializer = self.get_serializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)