diff --git a/backend/Dockerfile b/backend/Dockerfile index 3ba994e1..be867a9a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -49,6 +49,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ nginx \ memcached \ supervisor \ + fonts-noto-core \ && apt-get clean && rm -rf /var/lib/apt/lists/* # Copy Python packages from builder diff --git a/backend/server/achievements/admin.py b/backend/server/achievements/admin.py index af087ce9..ef3abe3c 100644 --- a/backend/server/achievements/admin.py +++ b/backend/server/achievements/admin.py @@ -1,9 +1,44 @@ from django.contrib import admin from allauth.account.decorators import secure_admin_login -from achievements.models import Achievement, UserAchievement -admin.autodiscover() +from achievements.models import Achievement, UserAchievement +from main.admin_utils import admin_image_preview + admin.site.login = secure_admin_login(admin.site.login) -admin.site.register(Achievement) -admin.site.register(UserAchievement) \ No newline at end of file + +@admin.register(Achievement) +class AchievementAdmin(admin.ModelAdmin): + list_display = ('name', 'key', 'type', 'icon_preview', 'earned_count') + list_filter = ('type',) + search_fields = ('name', 'key', 'description') + readonly_fields = ('icon_preview_large',) + ordering = ('name',) + + fieldsets = ( + (None, {'fields': ('name', 'key', 'type', 'description')}), + ('Rules', {'fields': ('condition',)}), + ('Art', {'fields': ('icon', 'icon_preview_large')}), + ) + + @admin.display(description='Icon') + def icon_preview(self, obj): + return admin_image_preview(obj.icon, 32, 32) + + @admin.display(description='Icon preview') + def icon_preview_large(self, obj): + return admin_image_preview(obj.icon, 96, 96) + + @admin.display(description='Users earned') + def earned_count(self, obj): + return UserAchievement.objects.filter(achievement=obj).count() + + +@admin.register(UserAchievement) +class UserAchievementAdmin(admin.ModelAdmin): + list_display = ('user', 'achievement', 'earned_at') + list_filter = ('achievement', 'earned_at') + search_fields = ('user__username', 'achievement__name', 'achievement__key') + autocomplete_fields = ('user', 'achievement') + date_hierarchy = 'earned_at' + readonly_fields = ('earned_at',) diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index fcd13127..2ff0b14a 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,214 +1,685 @@ -from django.contrib import admin -from django.utils.html import mark_safe, format_html -from django.urls import reverse -from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem, CollectionItineraryDay -from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity +from django.contrib import admin, messages +from django.contrib.contenttypes.models import ContentType from allauth.account.decorators import secure_admin_login -from main.utils import build_media_url -admin.autodiscover() +from adventures.models import ( + Activity, + Category, + Checklist, + ChecklistItem, + Collection, + CollectionInvite, + CollectionItineraryDay, + CollectionItineraryItem, + ContentAttachment, + ContentImage, + Location, + Lodging, + Note, + Trail, + Transportation, + Visit, +) +from adventures.utils.geo import has_coordinates +from main.admin_utils import ( + TIMESTAMP_READONLY, + UUID_READONLY, + AdventureLogGeoModelAdmin, + CoordinatesDisplayMixin, + TimestampedAdminMixin, + admin_image_preview, + coordinate_display, + generic_object_admin_link, +) + admin.site.login = secure_admin_login(admin.site.login) -@admin.action(description="Trigger geocoding") + +@admin.register(ContentType) +class ContentTypeAdmin(admin.ModelAdmin): + search_fields = ('app_label', 'model') + list_display = ('app_label', 'model') + ordering = ('app_label', 'model') + + +# --------------------------------------------------------------------------- +# Admin actions +# --------------------------------------------------------------------------- + +@admin.action(description='Trigger reverse geocoding for selected locations') def trigger_geocoding(modeladmin, request, queryset): count = 0 + skipped = 0 for location in queryset: + if not has_coordinates(location.coordinates): + skipped += 1 + continue try: - location.save() # Triggers geocoding logic in your model + location.save() count += 1 - except Exception as e: - modeladmin.message_user(request, f"Error geocoding {location}: {e}", level='error') - modeladmin.message_user(request, f"Geocoding triggered for {count} locations.", level='success') - + except Exception as exc: + modeladmin.message_user( + request, + f'Error geocoding {location}: {exc}', + level=messages.ERROR, + ) + if count: + modeladmin.message_user( + request, + f'Geocoding triggered for {count} location(s).', + level=messages.SUCCESS, + ) + if skipped: + modeladmin.message_user( + request, + f'Skipped {skipped} location(s) without coordinates.', + level=messages.WARNING, + ) -class LocationAdmin(admin.ModelAdmin): - list_display = ('name', 'get_category', 'get_visit_count', 'user', 'is_public') - list_filter = ( 'user', 'is_public') - search_fields = ('name',) - readonly_fields = ('city', 'region', 'country') +# --------------------------------------------------------------------------- +# Inlines +# --------------------------------------------------------------------------- + +class VisitInline(admin.TabularInline): + model = Visit + extra = 0 + fields = ('start_date', 'end_date', 'timezone', 'notes') + show_change_link = True + ordering = ('-start_date',) + + +class TrailInline(admin.TabularInline): + model = Trail + extra = 0 + fields = ('name', 'link', 'wanderer_id', 'wanderer_author_username') + show_change_link = True + + +class ActivityInline(admin.TabularInline): + model = Activity + extra = 0 + fields = ('name', 'sport_type', 'distance', 'moving_time', 'start_date') + show_change_link = True + readonly_fields = fields + can_delete = False + max_num = 0 + + +class ChecklistItemInline(admin.TabularInline): + model = ChecklistItem + extra = 1 + fields = ('name', 'is_checked', 'user') + + +class CollectionInviteInline(admin.TabularInline): + model = CollectionInvite + extra = 0 + autocomplete_fields = ('invited_user',) + readonly_fields = TIMESTAMP_READONLY + + +class CollectionItineraryDayInline(admin.TabularInline): + model = CollectionItineraryDay + extra = 0 + fields = ('date', 'name', 'description') + ordering = ('date',) + + +# --------------------------------------------------------------------------- +# Adventures models +# --------------------------------------------------------------------------- + +@admin.register(Location) +class LocationAdmin(CoordinatesDisplayMixin, AdventureLogGeoModelAdmin): + list_display = ( + 'name', + 'user', + 'category_display', + 'display_coordinates', + 'is_public', + 'visit_count', + 'city', + 'region', + 'country', + 'updated_at', + ) + list_filter = ('is_public', 'user', 'category', 'country', 'region') + search_fields = ( + 'name', + 'location', + 'description', + 'tags', + 'user__username', + 'city__name', + 'region__name', + 'country__name', + ) + list_select_related = ('user', 'category', 'city', 'region', 'country') + autocomplete_fields = ('user', 'category', 'city', 'region', 'country') + filter_horizontal = ('collections',) + readonly_fields = ( + UUID_READONLY + + TIMESTAMP_READONLY + + ('display_coordinates_readonly', 'city', 'region', 'country') + ) actions = [trigger_geocoding] + inlines = [VisitInline, TrailInline] + date_hierarchy = 'updated_at' - def get_category(self, obj): - if obj.category and obj.category.display_name and obj.category.icon: - return obj.category.display_name + ' ' + obj.category.icon - elif obj.category and obj.category.name: - return obj.category.name - else: - return 'No Category' - - get_category.short_description = 'Category' - - def get_visit_count(self, obj): - return obj.visits.count() - - get_visit_count.short_description = 'Visit Count' - - -class CountryAdmin(admin.ModelAdmin): - list_display = ('name', 'country_code', 'number_of_regions') - list_filter = ('subregion',) - search_fields = ('name', 'country_code') - - def number_of_regions(self, obj): - return Region.objects.filter(country=obj).count() - - number_of_regions.short_description = 'Number of Regions' - - -class RegionAdmin(admin.ModelAdmin): - list_display = ('name', 'country', 'number_of_visits') - list_filter = ('country',) - search_fields = ('name', 'country__name') - # list_filter = ('country', 'number_of_visits') - - def number_of_visits(self, obj): - return VisitedRegion.objects.filter(region=obj).count() - - number_of_visits.short_description = 'Number of Visits' - -class CityAdmin(admin.ModelAdmin): - list_display = ('name', 'region', 'country') - list_filter = ('region', 'region__country') - search_fields = ('name', 'region__name', 'region__country__name') - - def country(self, obj): - return obj.region.country.name - - country.short_description = 'Country' - -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin -from users.models import CustomUser - -class CustomUserAdmin(UserAdmin): - model = CustomUser - list_display = ['username', 'is_staff', 'is_active', 'image_display', 'measurement_system', 'default_currency', 'map_style'] - readonly_fields = ('uuid',) - search_fields = ('username',) - fieldsets = UserAdmin.fieldsets + ( + fieldsets = ( ( None, { 'fields': ( - 'profile_pic', - 'uuid', - 'public_profile', - 'disable_password', - 'measurement_system', - 'default_currency', - 'map_style', - ) + 'user', + 'name', + 'category', + 'location', + 'description', + 'tags', + 'rating', + 'price', + 'link', + 'is_public', + 'collections', + ), + }, + ), + ( + 'Map', + { + 'fields': ('coordinates', 'display_coordinates_readonly'), + 'description': 'Pick a point on the map. Saving triggers reverse geocoding when coordinates are set.', + }, + ), + ( + 'Geocoded places', + { + 'classes': ('collapse',), + 'fields': ('city', 'region', 'country'), + }, + ), + ( + 'Metadata', + {'classes': ('collapse',), 'fields': TIMESTAMP_READONLY}, + ), + ) + + @admin.display(description='Category') + def category_display(self, obj): + if not obj.category: + return '—' + icon = obj.category.icon or '' + label = obj.category.display_name or obj.category.name + return f'{label} {icon}'.strip() + + @admin.display(description='Visits', ordering='visits') + def visit_count(self, obj): + return obj.visits.count() + + @admin.display(description='Coordinates (lat, lon)') + def display_coordinates_readonly(self, obj): + return coordinate_display(obj.coordinates) + + +@admin.register(Visit) +class VisitAdmin(TimestampedAdminMixin, admin.ModelAdmin): + list_display = ('location', 'start_date', 'end_date', 'timezone', 'activity_count') + list_filter = ('timezone', 'location__user') + search_fields = ('location__name', 'notes', 'location__user__username') + autocomplete_fields = ('location',) + date_hierarchy = 'start_date' + inlines = [ActivityInline] + readonly_fields = UUID_READONLY + TIMESTAMP_READONLY + + fieldsets = ( + (None, {'fields': ('location', 'start_date', 'end_date', 'timezone', 'notes')}), + ('Metadata', {'classes': ('collapse',), 'fields': UUID_READONLY + TIMESTAMP_READONLY}), + ) + + @admin.display(description='Activities') + def activity_count(self, obj): + return obj.activities.count() + + +@admin.register(Collection) +class CollectionAdmin(TimestampedAdminMixin, admin.ModelAdmin): + list_display = ( + 'name', + 'user', + 'is_public', + 'is_archived', + 'start_date', + 'end_date', + 'location_count', + 'updated_at', + ) + list_filter = ('is_public', 'is_archived', 'user') + search_fields = ('name', 'description', 'user__username') + autocomplete_fields = ('user', 'primary_image') + filter_horizontal = ('shared_with',) + readonly_fields = UUID_READONLY + TIMESTAMP_READONLY + inlines = [CollectionInviteInline, CollectionItineraryDayInline] + date_hierarchy = 'start_date' + + fieldsets = ( + ( + None, + { + 'fields': ( + 'user', + 'name', + 'description', + 'link', + 'is_public', + 'is_archived', + 'primary_image', + ), + }, + ), + ('Dates', {'fields': ('start_date', 'end_date')}), + ('Sharing', {'fields': ('shared_with',)}), + ('Metadata', {'classes': ('collapse',), 'fields': UUID_READONLY + TIMESTAMP_READONLY}), + ) + + @admin.display(description='Locations') + def location_count(self, obj): + return obj.locations.count() + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ('display_name', 'icon', 'name', 'user', 'location_count') + list_filter = ('user',) + search_fields = ('name', 'display_name', 'user__username') + autocomplete_fields = ('user',) + readonly_fields = UUID_READONLY + + @admin.display(description='Locations') + def location_count(self, obj): + return Location.objects.filter(category=obj).count() + + +class TransportEndpointsDisplayMixin: + @admin.display(description='Origin') + def display_origin(self, obj): + return coordinate_display(obj.origin) + + @admin.display(description='Destination') + def display_destination(self, obj): + return coordinate_display(obj.destination) + + +@admin.register(Transportation) +class TransportationAdmin( + TransportEndpointsDisplayMixin, + AdventureLogGeoModelAdmin, +): + list_display = ( + 'name', + 'type', + 'user', + 'collection', + 'date', + 'display_origin', + 'display_destination', + 'is_public', + ) + list_filter = ('type', 'is_public', 'user', 'collection') + search_fields = ( + 'name', + 'description', + 'from_location', + 'to_location', + 'flight_number', + 'user__username', + 'collection__name', + ) + autocomplete_fields = ('user', 'collection') + readonly_fields = UUID_READONLY + TIMESTAMP_READONLY + ( + 'origin_coords_readonly', + 'destination_coords_readonly', + ) + date_hierarchy = 'date' + + fieldsets = ( + ( + None, + { + 'fields': ( + 'user', + 'collection', + 'type', + 'name', + 'description', + 'rating', + 'price', + 'link', + 'is_public', + ), + }, + ), + ('Schedule', {'fields': ('date', 'end_date', 'start_timezone', 'end_timezone')}), + ( + 'Route', + { + 'fields': ( + 'from_location', + 'to_location', + 'start_code', + 'end_code', + 'flight_number', + 'origin', + 'origin_coords_readonly', + 'destination', + 'destination_coords_readonly', + ), + }, + ), + ('Metadata', {'classes': ('collapse',), 'fields': UUID_READONLY + TIMESTAMP_READONLY}), + ) + + @admin.display(description='Origin (lat, lon)') + def origin_coords_readonly(self, obj): + return coordinate_display(obj.origin) + + @admin.display(description='Destination (lat, lon)') + def destination_coords_readonly(self, obj): + return coordinate_display(obj.destination) + + +@admin.register(Lodging) +class LodgingAdmin(CoordinatesDisplayMixin, AdventureLogGeoModelAdmin): + list_display = ( + 'name', + 'type', + 'user', + 'collection', + 'check_in', + 'check_out', + 'display_coordinates', + 'is_public', + ) + list_filter = ('type', 'is_public', 'user', 'collection') + search_fields = ('name', 'location', 'description', 'reservation_number', 'user__username') + autocomplete_fields = ('user', 'collection') + readonly_fields = UUID_READONLY + TIMESTAMP_READONLY + ('display_coordinates_readonly',) + date_hierarchy = 'check_in' + + fieldsets = ( + ( + None, + { + 'fields': ( + 'user', + 'collection', + 'name', + 'type', + 'description', + 'rating', + 'price', + 'link', + 'reservation_number', + 'is_public', + ), + }, + ), + ('Stay', {'fields': ('check_in', 'check_out', 'timezone', 'location')}), + ( + 'Map', + {'fields': ('coordinates', 'display_coordinates_readonly')}, + ), + ('Metadata', {'classes': ('collapse',), 'fields': UUID_READONLY + TIMESTAMP_READONLY}), + ) + + @admin.display(description='Coordinates (lat, lon)') + def display_coordinates_readonly(self, obj): + return coordinate_display(obj.coordinates) + + +@admin.register(Note) +class NoteAdmin(TimestampedAdminMixin, admin.ModelAdmin): + list_display = ('name', 'user', 'collection', 'date', 'is_public', 'updated_at') + list_filter = ('is_public', 'user', 'collection') + search_fields = ('name', 'content', 'user__username') + autocomplete_fields = ('user', 'collection') + readonly_fields = UUID_READONLY + TIMESTAMP_READONLY + date_hierarchy = 'date' + + +@admin.register(Checklist) +class ChecklistAdmin(TimestampedAdminMixin, admin.ModelAdmin): + list_display = ('name', 'user', 'collection', 'date', 'is_public', 'item_count') + list_filter = ('is_public', 'user', 'collection') + search_fields = ('name', 'user__username') + autocomplete_fields = ('user', 'collection') + readonly_fields = UUID_READONLY + TIMESTAMP_READONLY + inlines = [ChecklistItemInline] + + @admin.display(description='Items') + def item_count(self, obj): + return ChecklistItem.objects.filter(checklist=obj).count() + + +@admin.register(ChecklistItem) +class ChecklistItemAdmin(TimestampedAdminMixin, admin.ModelAdmin): + list_display = ('name', 'checklist', 'user', 'is_checked') + list_filter = ('is_checked', 'user', 'checklist') + search_fields = ('name', 'checklist__name', 'user__username') + autocomplete_fields = ('user', 'checklist') + readonly_fields = UUID_READONLY + TIMESTAMP_READONLY + + +@admin.register(Trail) +class TrailAdmin(admin.ModelAdmin): + list_display = ( + 'name', + 'user', + 'location', + 'wanderer_id', + 'has_link', + 'created_at', + ) + list_filter = ('user',) + search_fields = ( + 'name', + 'link', + 'wanderer_id', + 'location__name', + 'user__username', + ) + autocomplete_fields = ('user', 'location') + readonly_fields = ('created_at',) + UUID_READONLY + + @admin.display(boolean=True, description='External link') + def has_link(self, obj): + return bool(obj.link) + + +class ActivityEndpointsDisplayMixin: + @admin.display(description='Start') + def display_start(self, obj): + return coordinate_display(obj.start_point) + + @admin.display(description='End') + def display_end(self, obj): + return coordinate_display(obj.end_point) + + +@admin.register(Activity) +class ActivityAdmin(ActivityEndpointsDisplayMixin, AdventureLogGeoModelAdmin): + list_display = ( + 'name', + 'user', + 'visit', + 'sport_type', + 'distance', + 'elevation_gain', + 'moving_time', + 'display_start', + 'display_end', + ) + list_filter = ('sport_type', 'user') + search_fields = ('name', 'external_service_id', 'user__username', 'visit__location__name') + autocomplete_fields = ('user', 'visit', 'trail') + readonly_fields = UUID_READONLY + ( + 'start_coords_readonly', + 'end_coords_readonly', + ) + date_hierarchy = 'start_date' + + fieldsets = ( + ( + None, + { + 'fields': ( + 'user', + 'visit', + 'trail', + 'name', + 'sport_type', + 'external_service_id', + 'gpx_file', + ), + }, + ), + ( + 'Metrics', + { + 'fields': ( + 'distance', + 'moving_time', + 'elapsed_time', + 'rest_time', + 'elevation_gain', + 'elevation_loss', + 'elev_high', + 'elev_low', + 'average_speed', + 'max_speed', + 'average_cadence', + 'calories', + ), + }, + ), + ('Schedule', {'fields': ('start_date', 'start_date_local', 'timezone')}), + ( + 'GPS endpoints', + { + 'fields': ( + 'start_point', + 'start_coords_readonly', + 'end_point', + 'end_coords_readonly', + ), }, ), ) - def image_display(self, obj): - if obj.profile_pic: - image_url = build_media_url(obj.profile_pic.name) - if image_url: - return mark_safe(f'{}', admin_url, str(linked_obj)) - except Exception: - # Fallback to plain text if any error (object not registered, missing id, etc.) - return str(linked_obj) + return generic_object_admin_link(obj, attr='item') - object_link.short_description = 'Item' + object_link.short_description = 'Linked item' + + +@admin.register(CollectionItineraryDay) +class CollectionItineraryDayAdmin(TimestampedAdminMixin, admin.ModelAdmin): + list_display = ('collection', 'date', 'name') + list_filter = ('collection',) + search_fields = ('collection__name', 'name', 'description') + autocomplete_fields = ('collection',) + readonly_fields = UUID_READONLY + TIMESTAMP_READONLY + ordering = ('collection', 'date') -admin.site.register(CustomUser, CustomUserAdmin) -admin.site.register(Location, LocationAdmin) -admin.site.register(Collection, CollectionAdmin) -admin.site.register(Visit, VisitAdmin) -admin.site.register(Country, CountryAdmin) -admin.site.register(Region, RegionAdmin) -admin.site.register(VisitedRegion) -admin.site.register(Transportation) -admin.site.register(Note) -admin.site.register(Checklist) -admin.site.register(ChecklistItem) -admin.site.register(ContentImage, ContentImageImageAdmin) -admin.site.register(Category, CategoryAdmin) -admin.site.register(City, CityAdmin) -admin.site.register(VisitedCity) -admin.site.register(ContentAttachment) -admin.site.register(Lodging) -admin.site.register(CollectionInvite, CollectionInviteAdmin) -admin.site.register(Trail) -admin.site.register(Activity, ActivityAdmin) -admin.site.register(CollectionItineraryItem, CollectionItineraryItemAdmin) -admin.site.register(CollectionItineraryDay) admin.site.site_header = 'AdventureLog Admin' -admin.site.site_title = 'AdventureLog Admin Site' -admin.site.index_title = 'Welcome to AdventureLog Admin Page' +admin.site.site_title = 'AdventureLog Admin' +admin.site.index_title = 'AdventureLog administration' diff --git a/backend/server/adventures/migrations/0073_pointfield_geography.py b/backend/server/adventures/migrations/0073_pointfield_geography.py new file mode 100644 index 00000000..fee171ff --- /dev/null +++ b/backend/server/adventures/migrations/0073_pointfield_geography.py @@ -0,0 +1,96 @@ +# Generated manually for PostGIS PointField migration + +from django.contrib.gis.geos import Point +from django.db import migrations +import django.contrib.gis.db.models.fields + + +def forwards_fill_points(apps, schema_editor): + Location = apps.get_model('adventures', 'Location') + for row in Location.objects.exclude(latitude__isnull=True).exclude(longitude__isnull=True).iterator(): + row.coordinates = Point(float(row.longitude), float(row.latitude), srid=4326) + row.save(update_fields=['coordinates']) + + Lodging = apps.get_model('adventures', 'Lodging') + for row in Lodging.objects.exclude(latitude__isnull=True).exclude(longitude__isnull=True).iterator(): + row.coordinates = Point(float(row.longitude), float(row.latitude), srid=4326) + row.save(update_fields=['coordinates']) + + Transportation = apps.get_model('adventures', 'Transportation') + for row in Transportation.objects.iterator(): + update_fields = [] + if row.origin_latitude is not None and row.origin_longitude is not None: + row.origin = Point(float(row.origin_longitude), float(row.origin_latitude), srid=4326) + update_fields.append('origin') + if row.destination_latitude is not None and row.destination_longitude is not None: + row.destination = Point( + float(row.destination_longitude), float(row.destination_latitude), srid=4326 + ) + update_fields.append('destination') + if update_fields: + row.save(update_fields=update_fields) + + Activity = apps.get_model('adventures', 'Activity') + for row in Activity.objects.iterator(): + update_fields = [] + if row.start_lat is not None and row.start_lng is not None: + row.start_point = Point(float(row.start_lng), float(row.start_lat), srid=4326) + update_fields.append('start_point') + if row.end_lat is not None and row.end_lng is not None: + row.end_point = Point(float(row.end_lng), float(row.end_lat), srid=4326) + update_fields.append('end_point') + if update_fields: + row.save(update_fields=update_fields) + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0072_trail_wanderer_author_fields'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='coordinates', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + migrations.AddField( + model_name='lodging', + name='coordinates', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + migrations.AddField( + model_name='transportation', + name='origin', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + migrations.AddField( + model_name='transportation', + name='destination', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + migrations.AddField( + model_name='activity', + name='start_point', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + migrations.AddField( + model_name='activity', + name='end_point', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + migrations.RunPython(forwards_fill_points, migrations.RunPython.noop), + migrations.RemoveField(model_name='location', name='latitude'), + migrations.RemoveField(model_name='location', name='longitude'), + migrations.RemoveField(model_name='lodging', name='latitude'), + migrations.RemoveField(model_name='lodging', name='longitude'), + migrations.RemoveField(model_name='transportation', name='origin_latitude'), + migrations.RemoveField(model_name='transportation', name='origin_longitude'), + migrations.RemoveField(model_name='transportation', name='destination_latitude'), + migrations.RemoveField(model_name='transportation', name='destination_longitude'), + migrations.RemoveField(model_name='activity', name='start_lat'), + migrations.RemoveField(model_name='activity', name='start_lng'), + migrations.RemoveField(model_name='activity', name='end_lat'), + migrations.RemoveField(model_name='activity', name='end_lng'), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 1f9b7903..012a065d 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -1,7 +1,9 @@ import os import uuid +from django.contrib.gis.db import models as gis_models from django.db import models from django.utils.deconstruct import deconstructible +from adventures.utils.geo import has_coordinates, point_to_lat_lon from adventures.managers import LocationManager import threading from django.contrib.auth import get_user_model @@ -23,12 +25,13 @@ def background_geocode_and_assign(location_id: str): print(f"[Location Geocode Thread] Starting geocode for location {location_id}") try: location = Location.objects.get(id=location_id) - if not (location.latitude and location.longitude): + if not has_coordinates(location.coordinates): return - + + latitude, longitude = point_to_lat_lon(location.coordinates) from adventures.services.geocoding.reverse import reverse_geocode as reverse_geocode_service is_visited = location.is_visited_status() - selection = reverse_geocode_service(location.latitude, location.longitude, location.user) + selection = reverse_geocode_service(latitude, longitude, location.user) result = selection.data if 'region_id' in result: @@ -163,8 +166,7 @@ class Location(models.Model): price = MoneyField(max_digits=12, decimal_places=2, default_currency='USD', null=True, blank=True) link = models.URLField(blank=True, null=True, max_length=2083) is_public = models.BooleanField(default=False) - longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) - latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + coordinates = gis_models.PointField(srid=4326, null=True, blank=True) city = models.ForeignKey(City, on_delete=models.SET_NULL, blank=True, null=True) region = models.ForeignKey(Region, on_delete=models.SET_NULL, blank=True, null=True) country = models.ForeignKey(Country, on_delete=models.SET_NULL, blank=True, null=True) @@ -234,7 +236,7 @@ class Location(models.Model): if _skip_geocode: return result - if self.latitude and self.longitude: + if has_coordinates(self.coordinates): thread = threading.Thread(target=background_geocode_and_assign, args=(str(self.id),)) thread.daemon = True # Allows the thread to exit when the main program ends thread.start() @@ -322,10 +324,8 @@ class Transportation(models.Model): end_timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True) flight_number = models.CharField(max_length=100, blank=True, null=True) from_location = models.CharField(max_length=200, blank=True, null=True) - origin_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) - origin_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) - destination_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) - destination_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + origin = gis_models.PointField(srid=4326, null=True, blank=True) + destination = gis_models.PointField(srid=4326, null=True, blank=True) start_code = models.CharField(max_length=100, blank=True, null=True) # Could be airport code, station code, etc. end_code = models.CharField(max_length=100, blank=True, null=True) # Could be airport code, station code, etc. to_location = models.CharField(max_length=200, blank=True, null=True) @@ -564,8 +564,7 @@ class Lodging(models.Model): timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True) reservation_number = models.CharField(max_length=100, blank=True, null=True) price = MoneyField(max_digits=12, decimal_places=2, default_currency='USD', null=True, blank=True) - latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) - longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + coordinates = gis_models.PointField(srid=4326, null=True, blank=True) location = models.CharField(max_length=200, blank=True, null=True) is_public = models.BooleanField(default=False) collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True) @@ -685,10 +684,8 @@ class Activity(models.Model): calories = models.FloatField(blank=True, null=True) # Coordinates - start_lat = models.FloatField(blank=True, null=True) - start_lng = models.FloatField(blank=True, null=True) - end_lat = models.FloatField(blank=True, null=True) - end_lng = models.FloatField(blank=True, null=True) + start_point = gis_models.PointField(srid=4326, null=True, blank=True) + end_point = gis_models.PointField(srid=4326, null=True, blank=True) # Optional links external_service_id = models.CharField(max_length=100, blank=True, null=True) # E.g., Strava ID diff --git a/backend/server/adventures/pdf_assets/fonts/NotoEmoji-Regular.ttf b/backend/server/adventures/pdf_assets/fonts/NotoEmoji-Regular.ttf new file mode 100644 index 00000000..d786ee8f Binary files /dev/null and b/backend/server/adventures/pdf_assets/fonts/NotoEmoji-Regular.ttf differ diff --git a/backend/server/adventures/pdf_assets/fonts/NotoSans-Regular.ttf b/backend/server/adventures/pdf_assets/fonts/NotoSans-Regular.ttf new file mode 100644 index 00000000..d5522095 Binary files /dev/null and b/backend/server/adventures/pdf_assets/fonts/NotoSans-Regular.ttf differ diff --git a/backend/server/adventures/pdf_assets/fonts/OFL.txt b/backend/server/adventures/pdf_assets/fonts/OFL.txt new file mode 100644 index 00000000..801c3b06 --- /dev/null +++ b/backend/server/adventures/pdf_assets/fonts/OFL.txt @@ -0,0 +1,2 @@ +Noto Sans and Noto Emoji are licensed under the SIL Open Font License 1.1. +See https://openfontlicense.org/ diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 4d4e7cab..43e0fc66 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -6,6 +6,12 @@ from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySer from geopy.distance import geodesic from integrations.models import ImmichIntegration from adventures.utils.geojson import gpx_to_geojson +from adventures.utils.geo import point_to_lat_lon +from adventures.utils.serializer_geo_fields import ( + ActivityCoordinateMixin, + CoordinateSerializerMixin, + TransportationCoordinateMixin, +) import gpxpy import logging @@ -358,7 +364,7 @@ class TrailSerializer(CustomModelSerializer): return super().update(instance, validated_data) -class ActivitySerializer(CustomModelSerializer): +class ActivitySerializer(ActivityCoordinateMixin, CustomModelSerializer): geojson = serializers.SerializerMethodField() class Meta: @@ -420,7 +426,7 @@ class CalendarLocationSerializer(serializers.ModelSerializer): } -class LocationSerializer(CustomModelSerializer): +class LocationSerializer(CoordinateSerializerMixin, CustomModelSerializer): images = serializers.SerializerMethodField() visits = VisitSerializer(many=True, read_only=False, required=False) attachments = AttachmentSerializer(many=True, read_only=True) @@ -578,6 +584,7 @@ class LocationSerializer(CustomModelSerializer): return obj.is_visited_status() def create(self, validated_data): + validated_data = self._pop_lat_lon(validated_data) category_data = validated_data.pop('category', None) collections_data = validated_data.pop('collections', []) @@ -597,6 +604,7 @@ class LocationSerializer(CustomModelSerializer): return location def update(self, instance, validated_data): + validated_data = self._pop_lat_lon(validated_data) category_data = validated_data.pop('category', None) visits_data = validated_data.pop('visits', None) collections_data = validated_data.pop('collections', None) @@ -632,7 +640,7 @@ class LocationSerializer(CustomModelSerializer): return instance -class MapPinSerializer(serializers.ModelSerializer): +class MapPinSerializer(CoordinateSerializerMixin, serializers.ModelSerializer): is_visited = serializers.SerializerMethodField() category = CategorySerializer(read_only=True, required=False) @@ -644,7 +652,7 @@ class MapPinSerializer(serializers.ModelSerializer): def get_is_visited(self, obj): return obj.is_visited_status() -class TransportationSerializer(CustomModelSerializer): +class TransportationSerializer(TransportationCoordinateMixin, CustomModelSerializer): distance = serializers.SerializerMethodField() images = serializers.SerializerMethodField() attachments = serializers.SerializerMethodField() @@ -677,13 +685,12 @@ class TransportationSerializer(CustomModelSerializer): if gpx_distance is not None: return gpx_distance - if ( - obj.origin_latitude and obj.origin_longitude and - obj.destination_latitude and obj.destination_longitude - ): + o_lat, o_lon = point_to_lat_lon(obj.origin) + d_lat, d_lon = point_to_lat_lon(obj.destination) + if o_lat is not None and o_lon is not None and d_lat is not None and d_lon is not None: try: - origin = (float(obj.origin_latitude), float(obj.origin_longitude)) - destination = (float(obj.destination_latitude), float(obj.destination_longitude)) + origin = (o_lat, o_lon) + destination = (d_lat, d_lon) return round(geodesic(origin, destination).km, 2) except ValueError: return None @@ -751,7 +758,7 @@ class TransportationSerializer(CustomModelSerializer): and dt_value.time().microsecond == 0 ) -class LodgingSerializer(CustomModelSerializer): +class LodgingSerializer(CoordinateSerializerMixin, CustomModelSerializer): images = serializers.SerializerMethodField() attachments = serializers.SerializerMethodField() diff --git a/backend/server/adventures/services/collection_pdf.py b/backend/server/adventures/services/collection_pdf.py new file mode 100644 index 00000000..138c224a --- /dev/null +++ b/backend/server/adventures/services/collection_pdf.py @@ -0,0 +1,787 @@ +""" +Build a printable PDF itinerary from a Collection and its related content. +""" +from __future__ import annotations + +import io +import re +from datetime import date, datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +from django.db.models import Prefetch +from PIL import Image as PILImage +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER, TA_LEFT +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + Image, + PageBreak, + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, +) + +from adventures.models import ( + Checklist, + ChecklistItem, + Collection, + CollectionItineraryDay, + CollectionItineraryItem, + Location, + Lodging, + Note, + Transportation, +) +from adventures.services.pdf_fonts import ( + apply_fonts_to_styles, + ensure_pdf_fonts, + escape_xml, + pdf_markup, +) + +# Brand-adjacent palette (readable on white paper) +COLOR_PRIMARY = colors.HexColor('#0d9488') +COLOR_MUTED = colors.HexColor('#64748b') +COLOR_BORDER = colors.HexColor('#e2e8f0') +COLOR_LIGHT_BG = colors.HexColor('#f8fafc') + + +def get_collection_for_pdf(collection_id) -> Collection: + """Load a collection with relations needed for PDF export.""" + return ( + Collection.objects.filter(pk=collection_id) + .select_related('primary_image', 'user') + .prefetch_related( + Prefetch( + 'locations', + queryset=Location.objects.select_related( + 'category', 'country', 'region', 'city' + ).prefetch_related('visits'), + ), + 'transportation_set', + 'note_set', + Prefetch( + 'lodging_set', + queryset=Lodging.objects.all(), + ), + Prefetch( + 'checklist_set', + queryset=Checklist.objects.prefetch_related( + Prefetch('checklistitem_set', queryset=ChecklistItem.objects.order_by('name')) + ), + ), + Prefetch( + 'itinerary_items', + queryset=CollectionItineraryItem.objects.select_related('content_type').order_by( + 'date', 'order' + ), + ), + 'itinerary_days', + ) + .get() + ) + + +def _plain_text(value: Optional[str], max_length: int = 2000) -> str: + if not value: + return '' + text = str(value) + text = re.sub(r'!\[.*?\]\(.*?\)', '', text) + text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) + text = re.sub(r'#{1,6}\s*', '', text) + text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text) + text = re.sub(r'\*([^*]+)\*', r'\1', text) + text = re.sub(r'`([^`]+)`', r'\1', text) + text = text.replace('\r\n', '\n').replace('\r', '\n') + text = re.sub(r'\n{3,}', '\n\n', text).strip() + if len(text) > max_length: + return text[: max_length - 3] + '...' + return text + + +def _format_date(d: Optional[date]) -> str: + if not d: + return '' + return d.strftime('%A, %B %d, %Y') + + +def _format_short_date(d: Optional[date]) -> str: + if not d: + return '' + return d.strftime('%b %d, %Y') + + +def _format_datetime(dt: Optional[datetime], tz_name: Optional[str] = None) -> str: + if not dt: + return '' + if dt.hour == 0 and dt.minute == 0 and dt.second == 0: + return dt.strftime('%b %d, %Y') + suffix = f' ({tz_name})' if tz_name else '' + return dt.strftime('%b %d, %Y %I:%M %p').lstrip('0') + suffix + + +def _format_money(amount, currency: Optional[str]) -> str: + if amount is None: + return '' + cur = currency or 'USD' + try: + return f'{amount} {cur}' + except Exception: + return str(amount) + + +def _trip_day_count(start: Optional[date], end: Optional[date]) -> Optional[int]: + if not start or not end: + return None + return (end - start).days + 1 + + +def _build_lookups(collection: Collection) -> Dict[str, Dict[str, Any]]: + return { + 'location': {str(loc.id): loc for loc in collection.locations.all()}, + 'transportation': {str(t.id): t for t in collection.transportation_set.all()}, + 'lodging': {str(l.id): l for l in collection.lodging_set.all()}, + 'note': {str(n.id): n for n in collection.note_set.all()}, + 'checklist': {str(c.id): c for c in collection.checklist_set.all()}, + } + + +def _resolve_item( + itinerary_item: CollectionItineraryItem, lookups: Dict[str, Dict[str, Any]] +) -> Tuple[Optional[str], Optional[Any]]: + model = itinerary_item.content_type.model + obj = lookups.get(model, {}).get(str(itinerary_item.object_id)) + return model, obj + + +def _overnight_lodging_for_date(collection: Collection, day: date, scheduled_lodging_ids: set) -> List[Lodging]: + result = [] + target = day + for lodging in collection.lodging_set.all(): + if str(lodging.id) not in scheduled_lodging_ids: + continue + if not lodging.check_in or not lodging.check_out: + continue + check_in = lodging.check_in.date() + check_out = lodging.check_out.date() + if check_in <= target < check_out: + result.append(lodging) + return result + + +def _iter_trip_dates(start: date, end: date): + current = start + while current <= end: + yield current + current += timedelta(days=1) + + +def _scheduled_keys(collection: Collection) -> set: + keys = set() + for item in collection.itinerary_items.all(): + keys.add((item.content_type.model, str(item.object_id))) + return keys + + +def _group_itinerary_days(collection: Collection) -> Tuple[List[Dict[str, Any]], List[CollectionItineraryItem], Dict[str, Dict[str, Any]]]: + """Mirror the web planner: one section per calendar day in the trip range.""" + lookups = _build_lookups(collection) + day_meta = {d.date: d for d in collection.itinerary_days.all()} + + dated_items: Dict[date, List[CollectionItineraryItem]] = {} + global_items: List[CollectionItineraryItem] = [] + scheduled_lodging_ids = set() + + for item in collection.itinerary_items.all(): + model = item.content_type.model + if model == 'lodging': + scheduled_lodging_ids.add(str(item.object_id)) + if item.is_global: + global_items.append(item) + elif item.date: + dated_items.setdefault(item.date, []).append(item) + + for items in dated_items.values(): + items.sort(key=lambda x: x.order) + + start = collection.start_date + end = collection.end_date + if not start or not end: + if dated_items: + dates = sorted(dated_items.keys()) + start = dates[0] + end = dates[-1] + else: + return [] + + days: List[Dict[str, Any]] = [] + for day in _iter_trip_dates(start, end): + resolved = [] + for it in dated_items.get(day, []): + model, obj = _resolve_item(it, lookups) + if obj is not None: + resolved.append({'order': it.order, 'type': model, 'object': obj}) + + overnight = _overnight_lodging_for_date(collection, day, scheduled_lodging_ids) + meta = day_meta.get(day) + days.append( + { + 'date': day, + 'display_date': _format_date(day), + 'day_name': meta.name if meta and meta.name else None, + 'day_description': _plain_text(meta.description) if meta and meta.description else None, + 'items': resolved, + 'overnight_lodging': overnight, + } + ) + return days, global_items, lookups + + +def _item_type_label(model: str) -> str: + return { + 'location': 'Location', + 'transportation': 'Transport', + 'lodging': 'Stay', + 'note': 'Note', + 'checklist': 'Checklist', + }.get(model, model.title()) + + +def _location_paragraphs(location: Location, styles) -> List: + flowables = [] + title = pdf_markup(location.name or 'Location') + category = '' + if location.category: + icon = location.category.icon or '' + label = location.category.display_name or location.category.name + category = pdf_markup(f'{icon} {label}'.strip()) + flowables.append(Paragraph(f'{title}{f" {category}" if category else ""}', styles['ItemTitle'])) + + if location.location: + flowables.append(Paragraph(pdf_markup(location.location), styles['ItemMeta'])) + + geo_bits = [] + if location.city: + geo_bits.append(location.city.name) + if location.region: + geo_bits.append(location.region.name) + if location.country: + geo_bits.append(location.country.name) + if geo_bits: + flowables.append(Paragraph(pdf_markup(', '.join(geo_bits)), styles['ItemMeta'])) + + for visit in location.visits.all().order_by('start_date'): + start = visit.start_date.date() if visit.start_date else None + end = visit.end_date.date() if visit.end_date else None + if start and end and end != start: + flowables.append( + Paragraph( + f'Visit: {_format_short_date(start)} – {_format_short_date(end)}', + styles['ItemMeta'], + ) + ) + elif start: + flowables.append(Paragraph(f'Visit: {_format_short_date(start)}', styles['ItemMeta'])) + + desc = _plain_text(location.description) + if desc: + flowables.append(Paragraph(pdf_markup(desc), styles['ItemBody'])) + + extras = [] + if location.rating is not None: + extras.append(f'Rating: {location.rating}/5') + if location.price is not None: + extras.append(_format_money(location.price.amount, str(location.price.currency))) + if location.link: + extras.append(f'Link') + if extras: + flowables.append(Paragraph(' · '.join(extras), styles['ItemMeta'])) + + return flowables + + +def _transportation_paragraphs(transport: Transportation, styles) -> List: + flowables = [] + title = pdf_markup(transport.name or transport.type or 'Transport') + flowables.append(Paragraph(f'{title} ({pdf_markup(transport.type)})', styles['ItemTitle'])) + + route_parts = [] + if transport.from_location or transport.start_code: + origin = transport.from_location or transport.start_code or '' + route_parts.append(pdf_markup(origin)) + if transport.to_location or transport.end_code: + dest = transport.to_location or transport.end_code or '' + if route_parts: + route_parts.append('→') + route_parts.append(pdf_markup(dest)) + if route_parts: + flowables.append(Paragraph(' '.join(route_parts), styles['ItemMeta'])) + + if transport.date: + line = _format_datetime(transport.date, transport.start_timezone) + if transport.end_date: + line += f' – {_format_datetime(transport.end_date, transport.end_timezone)}' + flowables.append(Paragraph(line, styles['ItemMeta'])) + + if transport.flight_number: + flowables.append(Paragraph(f'Flight: {pdf_markup(transport.flight_number)}', styles['ItemMeta'])) + + desc = _plain_text(transport.description) + if desc: + flowables.append(Paragraph(pdf_markup(desc), styles['ItemBody'])) + + extras = [] + if transport.rating is not None: + extras.append(f'Rating: {transport.rating}/5') + if transport.price is not None: + extras.append(_format_money(transport.price, transport.price_currency)) + if transport.link: + extras.append(f'Link') + if extras: + flowables.append(Paragraph(' · '.join(extras), styles['ItemMeta'])) + + return flowables + + +def _lodging_paragraphs(lodging: Lodging, styles) -> List: + flowables = [] + title = pdf_markup(lodging.name or 'Stay') + flowables.append( + Paragraph( + f'{title} ({pdf_markup(lodging.type)})', + styles['ItemTitle'], + ) + ) + if lodging.location: + flowables.append(Paragraph(pdf_markup(lodging.location), styles['ItemMeta'])) + if lodging.check_in or lodging.check_out: + check_in = _format_datetime(lodging.check_in, lodging.timezone) if lodging.check_in else '—' + check_out = _format_datetime(lodging.check_out, lodging.timezone) if lodging.check_out else '—' + flowables.append(Paragraph(f'Check-in: {check_in} · Check-out: {check_out}', styles['ItemMeta'])) + if lodging.reservation_number: + flowables.append(Paragraph(f'Confirmation: {pdf_markup(lodging.reservation_number)}', styles['ItemMeta'])) + + desc = _plain_text(lodging.description) + if desc: + flowables.append(Paragraph(pdf_markup(desc), styles['ItemBody'])) + + extras = [] + if lodging.rating is not None: + extras.append(f'Rating: {lodging.rating}/5') + if lodging.price is not None: + extras.append(_format_money(lodging.price, lodging.price_currency)) + if lodging.link: + extras.append(f'Link') + if extras: + flowables.append(Paragraph(' · '.join(extras), styles['ItemMeta'])) + + return flowables + + +def _note_paragraphs(note: Note, styles) -> List: + flowables = [] + flowables.append(Paragraph(f'{pdf_markup(note.name or "Note")}', styles['ItemTitle'])) + if note.date: + flowables.append(Paragraph(_format_short_date(note.date), styles['ItemMeta'])) + content = _plain_text(note.content) + if content: + flowables.append(Paragraph(pdf_markup(content), styles['ItemBody'])) + if note.links: + for link in note.links: + if link: + flowables.append( + Paragraph( + f'{pdf_markup(link)}', + styles['ItemMeta'], + ) + ) + return flowables + + +def _checklist_paragraphs(checklist: Checklist, styles, _body_font: str, _emoji_font: str | None) -> List: + flowables = [] + flowables.append(Paragraph(f'{pdf_markup(checklist.name or "Checklist")}', styles['ItemTitle'])) + if checklist.date: + flowables.append(Paragraph(_format_short_date(checklist.date), styles['ItemMeta'])) + mark_style = ParagraphStyle( + 'ChecklistMark', + parent=styles['ItemMeta'], + fontSize=10, + textColor=COLOR_MUTED, + ) + rows = [] + for item in checklist.checklistitem_set.all(): + mark = '☑' if item.is_checked else '☐' + rows.append([ + Paragraph(pdf_markup(mark), mark_style), + Paragraph(pdf_markup(item.name or ''), styles['ItemBody']), + ]) + if rows: + table = Table(rows, colWidths=[0.35 * inch, 6.0 * inch]) + table.setStyle( + TableStyle( + [ + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 0), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ] + ) + ) + flowables.append(table) + return flowables + + +def _item_flowables( + model: str, obj: Any, styles, body_font: str, emoji_font: str | None +) -> List: + if obj is None: + return [] + if model == 'location': + return _location_paragraphs(obj, styles) + if model == 'transportation': + return _transportation_paragraphs(obj, styles) + if model == 'lodging': + return _lodging_paragraphs(obj, styles) + if model == 'note': + return _note_paragraphs(obj, styles) + if model == 'checklist': + return _checklist_paragraphs(obj, styles, body_font, emoji_font) + return [] + + +def _item_card( + model: str, obj: Any, order: int, styles, body_font: str, emoji_font: str | None +) -> List: + """Render a single itinerary entry inside a shaded card.""" + inner = [ + Paragraph( + f'{order}. ' + f'{_item_type_label(model).upper()}', + styles['ItemLabel'], + ), + Spacer(1, 4), + ] + inner.extend(_item_flowables(model, obj, styles, body_font, emoji_font)) + card = Table([[inner]], colWidths=[6.5 * inch]) + card.setStyle( + TableStyle( + [ + ('BACKGROUND', (0, 0), (-1, -1), COLOR_LIGHT_BG), + ('BOX', (0, 0), (-1, -1), 0.5, COLOR_BORDER), + ('LEFTPADDING', (0, 0), (-1, -1), 12), + ('RIGHTPADDING', (0, 0), (-1, -1), 12), + ('TOPPADDING', (0, 0), (-1, -1), 10), + ('BOTTOMPADDING', (0, 0), (-1, -1), 10), + ] + ) + ) + return [card, Spacer(1, 10)] + + +def _cover_image_flowable(collection: Collection, max_width: float = 6.5 * inch) -> Optional[Image]: + image_field = None + if collection.primary_image and collection.primary_image.image: + image_field = collection.primary_image.image + if not image_field: + return None + try: + with image_field.open('rb') as fh: + pil = PILImage.open(fh) + pil = pil.convert('RGB') + buf = io.BytesIO() + pil.save(buf, format='JPEG', quality=85) + buf.seek(0) + img = Image(buf) + ratio = img.imageHeight / float(img.imageWidth) + img.drawWidth = max_width + img.drawHeight = max_width * ratio + if img.drawHeight > 3.5 * inch: + img.drawHeight = 3.5 * inch + img.drawWidth = img.drawHeight / ratio + return img + except Exception: + return None + + +def _section_heading(text: str, styles) -> List: + return [ + Paragraph(text, styles['SectionHeading']), + Spacer(1, 8), + ] + + +def _folder_sections( + collection: Collection, + styles, + body_font: str, + emoji_font: str | None, + *, + only_unscheduled: bool = False, + scheduled_keys: Optional[set] = None, +) -> List: + """List linked content by type (optionally excluding itinerary-scheduled items).""" + story = [] + scheduled_keys = scheduled_keys or set() + + def include(model: str, obj_id: str) -> bool: + if not only_unscheduled: + return True + return (model, str(obj_id)) not in scheduled_keys + + sections = [ + ('Locations', 'location', collection.locations.all()), + ('Transport', 'transportation', collection.transportation_set.all()), + ('Stays', 'lodging', collection.lodging_set.all()), + ('Notes', 'note', collection.note_set.all()), + ('Checklists', 'checklist', collection.checklist_set.all()), + ] + + order = 1 + for heading, model, queryset in sections: + items = [obj for obj in queryset if include(model, obj.id)] + if not items: + continue + story.extend(_section_heading(heading, styles)) + for obj in items: + story.extend(_item_card(model, obj, order, styles, body_font, emoji_font)) + order += 1 + return story + + +def build_collection_pdf(collection: Collection) -> bytes: + """Return PDF bytes for the given collection.""" + body_font, emoji_font = ensure_pdf_fonts() + + buffer = io.BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=letter, + leftMargin=0.75 * inch, + rightMargin=0.75 * inch, + topMargin=0.65 * inch, + bottomMargin=0.65 * inch, + title=collection.name or 'Trip', + author='AdventureLog', + ) + + base = getSampleStyleSheet() + styles = { + 'CoverTitle': ParagraphStyle( + 'CoverTitle', + parent=base['Title'], + fontSize=26, + leading=30, + alignment=TA_CENTER, + textColor=colors.HexColor('#0f172a'), + spaceAfter=12, + ), + 'CoverSubtitle': ParagraphStyle( + 'CoverSubtitle', + parent=base['Normal'], + fontSize=12, + leading=16, + alignment=TA_CENTER, + textColor=COLOR_MUTED, + spaceAfter=6, + ), + 'SectionHeading': ParagraphStyle( + 'SectionHeading', + parent=base['Heading2'], + fontSize=14, + leading=18, + textColor=COLOR_PRIMARY, + spaceBefore=8, + spaceAfter=4, + ), + 'DayHeading': ParagraphStyle( + 'DayHeading', + parent=base['Heading1'], + fontSize=16, + leading=20, + textColor=colors.HexColor('#0f172a'), + spaceBefore=14, + spaceAfter=2, + ), + 'DaySubheading': ParagraphStyle( + 'DaySubheading', + parent=base['Normal'], + fontSize=11, + leading=14, + textColor=COLOR_MUTED, + spaceAfter=8, + ), + 'ItemLabel': ParagraphStyle( + 'ItemLabel', + parent=base['Normal'], + fontSize=8, + leading=10, + textColor=COLOR_MUTED, + ), + 'ItemTitle': ParagraphStyle( + 'ItemTitle', + parent=base['Normal'], + fontSize=12, + leading=15, + textColor=colors.HexColor('#0f172a'), + spaceAfter=4, + ), + 'ItemMeta': ParagraphStyle( + 'ItemMeta', + parent=base['Normal'], + fontSize=9, + leading=12, + textColor=COLOR_MUTED, + spaceAfter=3, + ), + 'ItemBody': ParagraphStyle( + 'ItemBody', + parent=base['Normal'], + fontSize=10, + leading=13, + textColor=colors.HexColor('#334155'), + spaceAfter=4, + ), + 'Footer': ParagraphStyle( + 'Footer', + parent=base['Normal'], + fontSize=8, + leading=10, + alignment=TA_CENTER, + textColor=COLOR_MUTED, + ), + } + apply_fonts_to_styles(styles, body_font) + + story: List = [] + + # Cover + cover_img = _cover_image_flowable(collection) + if cover_img: + story.append(Spacer(1, 0.15 * inch)) + story.append(cover_img) + story.append(Spacer(1, 0.25 * inch)) + + story.append(Paragraph(pdf_markup(collection.name or 'Trip'), styles['CoverTitle'])) + + if collection.start_date and collection.end_date: + days = _trip_day_count(collection.start_date, collection.end_date) + date_line = f'{_format_short_date(collection.start_date)} – {_format_short_date(collection.end_date)}' + if days: + date_line += f' · {days} day{"s" if days != 1 else ""}' + story.append(Paragraph(date_line, styles['CoverSubtitle'])) + elif collection.start_date: + story.append(Paragraph(f'Starts {_format_short_date(collection.start_date)}', styles['CoverSubtitle'])) + else: + story.append(Paragraph('Trip folder', styles['CoverSubtitle'])) + + loc_count = collection.locations.count() + if loc_count: + story.append( + Paragraph( + f'{loc_count} location{"s" if loc_count != 1 else ""}', + styles['CoverSubtitle'], + ) + ) + + desc = _plain_text(collection.description) + if desc: + story.append(Spacer(1, 0.15 * inch)) + story.append( + Paragraph(pdf_markup(desc), styles['ItemBody']), + ) + + if collection.link: + story.append( + Paragraph( + f'{pdf_markup(collection.link)}', + styles['CoverSubtitle'], + ) + ) + + story.append(Spacer(1, 0.2 * inch)) + story.append(Paragraph('Generated by AdventureLog', styles['Footer'])) + story.append(PageBreak()) + + if collection.start_date and collection.end_date: + day_groups, global_items, lookups = _group_itinerary_days(collection) + + if day_groups: + story.extend(_section_heading('Day-by-day itinerary', styles)) + for day in day_groups: + day_title = day['display_date'] + if day.get('day_name'): + day_title = f'{day["day_name"]} — {day_title}' + story.append(Paragraph(day_title, styles['DayHeading'])) + if day.get('day_description'): + story.append( + Paragraph( + pdf_markup(day['day_description']), + styles['DaySubheading'], + ) + ) + + if day.get('overnight_lodging'): + names = ', '.join(pdf_markup(l.name) for l in day['overnight_lodging']) + story.append( + Paragraph(f'Overnight: {names}', styles['DaySubheading']), + ) + + if not day['items']: + story.append(Paragraph('No scheduled items this day.', styles['ItemMeta'])) + story.append(Spacer(1, 8)) + continue + + for idx, entry in enumerate(day['items'], start=1): + story.extend( + _item_card(entry['type'], entry['object'], idx, styles, body_font, emoji_font) + ) + + if global_items: + story.append(PageBreak()) + story.extend(_section_heading('Trip-wide items', styles)) + for idx, it in enumerate(sorted(global_items, key=lambda x: x.order), start=1): + model, obj = _resolve_item(it, lookups) + if obj is not None: + story.extend(_item_card(model, obj, idx, styles, body_font, emoji_font)) + + else: + story.extend(_section_heading('Itinerary', styles)) + story.append( + Paragraph( + 'No items are scheduled on the itinerary yet. See the reference sections below.', + styles['ItemMeta'], + ) + ) + story.append(Spacer(1, 12)) + + scheduled_keys = _scheduled_keys(collection) + appendix = _folder_sections( + collection, + styles, + body_font, + emoji_font, + only_unscheduled=True, + scheduled_keys=scheduled_keys, + ) + if appendix: + story.append(PageBreak()) + story.extend(_section_heading('Additional items (not on itinerary)', styles)) + story.extend(appendix) + else: + story.extend(_section_heading('Collection contents', styles)) + story.extend(_folder_sections(collection, styles, body_font, emoji_font)) + + doc.build(story) + return buffer.getvalue() + + +def pdf_filename_for_collection(collection: Collection) -> str: + raw = (collection.name or 'collection').strip() + safe = re.sub(r'[^\w\s-]', '', raw, flags=re.UNICODE) + safe = re.sub(r'[-\s]+', '_', safe).strip('_') or 'collection' + return f'{safe}_itinerary.pdf' diff --git a/backend/server/adventures/services/pdf_fonts.py b/backend/server/adventures/services/pdf_fonts.py new file mode 100644 index 00000000..58ce65fa --- /dev/null +++ b/backend/server/adventures/services/pdf_fonts.py @@ -0,0 +1,147 @@ +""" +Register Unicode-capable fonts for ReportLab PDF generation (including emoji). +""" +from __future__ import annotations + +import logging +import re +import unicodedata +from functools import lru_cache +from pathlib import Path + +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFError, TTFont + +logger = logging.getLogger(__name__) + +BODY_FONT = 'AdventureLogSans' +EMOJI_FONT = 'AdventureLogEmoji' + +_BUNDLED_DIR = Path(__file__).resolve().parent.parent / 'pdf_assets' / 'fonts' + +# System paths (Debian/Ubuntu fonts-noto-* packages) +_SYSTEM_FONT_CANDIDATES = { + BODY_FONT: [ + _BUNDLED_DIR / 'NotoSans-Regular.ttf', + Path('/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf'), + Path('/usr/share/fonts/opentype/noto/NotoSans-Regular.ttf'), + Path('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'), + ], + EMOJI_FONT: [ + _BUNDLED_DIR / 'NotoEmoji-Regular.ttf', + Path('/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf'), + Path('/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf'), + Path('/usr/share/fonts/truetype/noto/NotoSansSymbols-Regular.ttf'), + ], +} + +_registered = False + + +def _register_font(alias: str, path: Path) -> bool: + try: + pdfmetrics.registerFont(TTFont(alias, str(path))) + return True + except (TTFError, OSError, ValueError) as exc: + logger.debug('Could not register font %s from %s: %s', alias, path, exc) + return False + + +@lru_cache(maxsize=1) +def ensure_pdf_fonts() -> tuple[str, str | None]: + """ + Register body and emoji fonts once. Returns (body_font_name, emoji_font_name or None). + """ + global _registered + if _registered: + return BODY_FONT, EMOJI_FONT if EMOJI_FONT in pdfmetrics.getRegisteredFontNames() else None + + body_path = next((p for p in _SYSTEM_FONT_CANDIDATES[BODY_FONT] if p.is_file()), None) + if body_path: + _register_font(BODY_FONT, body_path) + else: + logger.warning('PDF body font not found; emoji and non-Latin text may not render') + + emoji_path = next((p for p in _SYSTEM_FONT_CANDIDATES[EMOJI_FONT] if p.is_file()), None) + emoji_registered = None + if emoji_path and _register_font(EMOJI_FONT, emoji_path): + emoji_registered = EMOJI_FONT + else: + logger.warning('PDF emoji font not found; category icons may not render') + + _registered = True + body = BODY_FONT if BODY_FONT in pdfmetrics.getRegisteredFontNames() else 'Helvetica' + return body, emoji_registered + + +def _char_is_emoji(c: str) -> bool: + if c in '\u200d\ufe0f\u20e3': + return True + o = ord(c) + if 0x1F1E6 <= o <= 0x1F1FF: + return True + if 0x1F300 <= o <= 0x1FAFF: + return True + if 0x2600 <= o <= 0x27BF: + return True + if 0x2300 <= o <= 0x23FF: + return True + if 0x1F600 <= o <= 0x1F64F: + return True + if 0x1F680 <= o <= 0x1F6FF: + return True + if 0x1F900 <= o <= 0x1F9FF: + return True + try: + return 'EMOJI' in unicodedata.name(c, '') + except ValueError: + return False + + +def _split_text_runs(text: str) -> list[tuple[str, bool]]: + if not text: + return [] + runs: list[tuple[str, bool]] = [] + buf: list[str] = [] + is_emoji: bool | None = None + for char in text: + char_emoji = _char_is_emoji(char) + if buf and char_emoji != is_emoji: + runs.append((''.join(buf), bool(is_emoji))) + buf = [] + buf.append(char) + is_emoji = char_emoji + if buf: + runs.append((''.join(buf), bool(is_emoji))) + return runs + + +def escape_xml(text: str) -> str: + return ( + text.replace('&', '&') + .replace('<', '<') + .replace('>', '>') + ) + + +def pdf_markup(text: str, *, emoji_font: str | None = None) -> str: + """ + Escape text for ReportLab Paragraph XML and wrap emoji runs in the emoji font. + """ + if not text: + return '' + _, emoji_registered = ensure_pdf_fonts() + font = emoji_font or emoji_registered + parts: list[str] = [] + for chunk, is_emoji in _split_text_runs(text): + escaped = escape_xml(chunk) + if is_emoji and font: + parts.append(f'{escaped}') + else: + parts.append(escaped) + return ''.join(parts).replace('\n', '
') + + +def apply_fonts_to_styles(styles: dict, body_font: str) -> None: + for style in styles.values(): + style.fontName = body_font diff --git a/backend/server/adventures/services/share_image.py b/backend/server/adventures/services/share_image.py new file mode 100644 index 00000000..87f8db2d --- /dev/null +++ b/backend/server/adventures/services/share_image.py @@ -0,0 +1,435 @@ +""" +Build branded PNG share cards for collections and locations (social media). +""" +from __future__ import annotations + +import io +import re +from datetime import date, datetime +from functools import lru_cache +from pathlib import Path +from typing import Optional, Tuple, Union + +import qrcode +from django.db.models import Prefetch +from PIL import Image as PILImage +from PIL import ImageDraw, ImageFont + +from adventures.models import Collection, ContentImage, Location, Visit +from adventures.services.collection_pdf import get_collection_for_pdf + +ASPECTS: dict[str, Tuple[int, int]] = { + 'square': (1080, 1080), + 'story': (1080, 1920), + 'landscape': (1200, 628), +} + +COLOR_PRIMARY = (13, 148, 136) +COLOR_MUTED = (100, 116, 139) +COLOR_LIGHT = (248, 250, 252) +COLOR_WHITE = (255, 255, 255) +COLOR_DARK = (15, 23, 42) + +_BUNDLED_DIR = Path(__file__).resolve().parent.parent / 'pdf_assets' / 'fonts' +_FONT_CANDIDATES = [ + _BUNDLED_DIR / 'NotoSans-Regular.ttf', + Path('/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf'), + Path('/usr/share/fonts/opentype/noto/NotoSans-Regular.ttf'), + Path('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'), +] +_BOLD_CANDIDATES = [ + _BUNDLED_DIR / 'NotoSans-Bold.ttf', + Path('/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf'), + Path('/usr/share/fonts/opentype/noto/NotoSans-Bold.ttf'), + Path('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'), +] + + +def valid_aspect(aspect: str) -> bool: + return aspect in ASPECTS + + +def aspect_dimensions(aspect: str) -> Tuple[int, int]: + if aspect not in ASPECTS: + raise ValueError(f'Invalid aspect: {aspect}') + return ASPECTS[aspect] + + +@lru_cache(maxsize=1) +def _body_font_path() -> Optional[Path]: + return next((p for p in _FONT_CANDIDATES if p.is_file()), None) + + +@lru_cache(maxsize=1) +def _bold_font_path() -> Optional[Path]: + return next((p for p in _BOLD_CANDIDATES if p.is_file()), None) + + +def _load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + path = (_bold_font_path() if bold else _body_font_path()) + if path: + try: + return ImageFont.truetype(str(path), size) + except OSError: + pass + return ImageFont.load_default() + + +def _format_short_date(d: Optional[date]) -> str: + if not d: + return '' + return d.strftime('%b %d, %Y') + + +def _format_date_range(start: Optional[date], end: Optional[date]) -> str: + if start and end: + if start == end: + return _format_short_date(start) + if start.year == end.year: + return f'{start.strftime("%b %d")} – {end.strftime("%b %d, %Y")}' + return f'{_format_short_date(start)} – {_format_short_date(end)}' + if start: + return f'From {_format_short_date(start)}' + if end: + return f'Until {_format_short_date(end)}' + return '' + + +def _text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int: + bbox = draw.textbbox((0, 0), text, font=font) + return bbox[2] - bbox[0] + + +def _truncate_to_width( + draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int +) -> str: + if not text: + return '' + if _text_width(draw, text, font) <= max_width: + return text + ellipsis = '...' + low, high = 0, len(text) + while low < high: + mid = (low + high + 1) // 2 + candidate = text[:mid].rstrip() + ellipsis + if _text_width(draw, candidate, font) <= max_width: + low = mid + else: + high = mid - 1 + return text[:low].rstrip() + ellipsis if low > 0 else ellipsis + + +def _open_image_field(image_field) -> Optional[PILImage.Image]: + if not image_field: + return None + try: + with image_field.open('rb') as fh: + img = PILImage.open(fh) + return img.convert('RGB') + except Exception: + return None + + +def _location_hero_image(location: Location) -> Optional[PILImage.Image]: + images = sorted(location.images.all(), key=lambda img: (not img.is_primary,)) + if not images: + return None + for img in images: + if img.image: + opened = _open_image_field(img.image) + if opened: + return opened + return None + + +def _collection_hero_image(collection: Collection) -> Optional[PILImage.Image]: + if collection.primary_image and collection.primary_image.image: + return _open_image_field(collection.primary_image.image) + for loc in collection.locations.all(): + hero = _location_hero_image(loc) + if hero: + return hero + return None + + +def _cover_crop(img: PILImage.Image, width: int, height: int) -> PILImage.Image: + target_ratio = width / height + src_w, src_h = img.size + src_ratio = src_w / src_h + if src_ratio > target_ratio: + new_w = int(src_h * target_ratio) + left = (src_w - new_w) // 2 + cropped = img.crop((left, 0, left + new_w, src_h)) + else: + new_h = int(src_w / target_ratio) + top = (src_h - new_h) // 2 + cropped = img.crop((0, top, src_w, top + new_h)) + return cropped.resize((width, height), PILImage.Resampling.LANCZOS) + + +def _gradient_background(width: int, height: int) -> PILImage.Image: + base = PILImage.new('RGB', (width, height), COLOR_PRIMARY) + overlay = PILImage.new('RGBA', (width, height)) + draw = ImageDraw.Draw(overlay) + for y in range(height): + alpha = int(180 * (y / max(height - 1, 1))) + draw.line([(0, y), (width, y)], fill=(15, 118, 110, alpha)) + return PILImage.alpha_composite(base.convert('RGBA'), overlay).convert('RGB') + + +def _apply_bottom_gradient(canvas: PILImage.Image, start_ratio: float = 0.35) -> PILImage.Image: + width, height = canvas.size + overlay = PILImage.new('RGBA', (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + start_y = int(height * start_ratio) + for y in range(start_y, height): + progress = (y - start_y) / max(height - start_y - 1, 1) + alpha = int(210 * progress) + draw.line([(0, y), (width, y)], fill=(15, 23, 42, alpha)) + return PILImage.alpha_composite(canvas.convert('RGBA'), overlay).convert('RGB') + + +def _make_qr(url: str, outer_size: int) -> PILImage.Image: + """ + Standard black-on-white QR with quiet zone, then a white mat for dark backgrounds. + """ + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=4, + ) + qr.add_data(url) + qr.make(fit=True) + core = qr.make_image(fill_color='black', back_color='white').convert('RGB') + + # Extra white mat so the code stays scannable on gradient/dark footers + mat_px = max(12, outer_size // 12) + matted = PILImage.new( + 'RGB', + (core.width + 2 * mat_px, core.height + 2 * mat_px), + COLOR_WHITE, + ) + matted.paste(core, (mat_px, mat_px)) + return matted.resize((outer_size, outer_size), PILImage.Resampling.LANCZOS) + + +def _draw_footer( + canvas: PILImage.Image, + width: int, + height: int, + username: str, + public_url: str, + padding: int, + qr_size: int, +) -> None: + draw = ImageDraw.Draw(canvas) + brand_font = _load_font(28, bold=True) + user_font = _load_font(22) + footer_y = height - padding - qr_size + + draw.text((padding, footer_y + 8), 'AdventureLog', font=brand_font, fill=COLOR_PRIMARY) + if username: + draw.text((padding, footer_y + 40), f'@{username}', font=user_font, fill=COLOR_WHITE) + + qr = _make_qr(public_url, qr_size) + canvas.paste(qr, (width - padding - qr_size, footer_y)) + + +def _compose_card( + *, + width: int, + height: int, + hero: Optional[PILImage.Image], + title: str, + subtitle: str, + stats: str, + username: str, + public_url: str, + highlights: Optional[list[str]] = None, +) -> bytes: + padding = max(48, width // 22) + if hero: + canvas = _cover_crop(hero, width, height) + canvas = _apply_bottom_gradient(canvas, start_ratio=0.3 if height > width else 0.45) + else: + canvas = _gradient_background(width, height) + + draw = ImageDraw.Draw(canvas) + + title_size = 72 if height >= 1500 else (56 if width >= 1000 else 48) + subtitle_size = 32 if height >= 1500 else 26 + stats_size = 24 if height >= 1500 else 22 + highlight_size = 22 + + title_font = _load_font(title_size, bold=True) + subtitle_font = _load_font(subtitle_size) + stats_font = _load_font(stats_size) + highlight_font = _load_font(highlight_size) + + content_width = width - 2 * padding + # Large enough to scan from a phone screen; scales with card size + qr_size = max(120, min(160, width // 7)) + text_bottom = height - padding - qr_size - 16 + + lines: list[tuple[str, ImageFont.ImageFont, Tuple[int, int, int]]] = [] + y = text_bottom + + if highlights: + for name in reversed(highlights[:3]): + line = f'• {_truncate_to_width(draw, name, highlight_font, content_width - 24)}' + lines.append((line, highlight_font, COLOR_LIGHT)) + if stats: + lines.append((_truncate_to_width(draw, stats, stats_font, content_width), stats_font, COLOR_LIGHT)) + if subtitle: + lines.append((_truncate_to_width(draw, subtitle, subtitle_font, content_width), subtitle_font, COLOR_LIGHT)) + title_line = _truncate_to_width(draw, title, title_font, content_width) + lines.append((title_line, title_font, COLOR_WHITE)) + + for text, font, color in lines: + bbox = draw.textbbox((0, 0), text, font=font) + line_h = bbox[3] - bbox[1] + y -= line_h + 12 + draw.text((padding, y), text, font=font, fill=color) + + _draw_footer(canvas, width, height, username, public_url, padding, qr_size) + + buf = io.BytesIO() + canvas.save(buf, format='PNG', optimize=True) + return buf.getvalue() + + +def get_location_for_share(location_id) -> Location: + return ( + Location.objects.filter(pk=location_id) + .select_related('user', 'city', 'region', 'country', 'category') + .prefetch_related('images', Prefetch('visits', queryset=Visit.objects.order_by('start_date'))) + .get() + ) + + +def _location_place_name(location: Location) -> str: + parts = [] + if location.city: + parts.append(location.city.name) + elif location.region: + parts.append(location.region.name) + if location.country: + parts.append(location.country.name) + if parts: + return ', '.join(parts) + return location.location or '' + + +def _location_visit_range(location: Location) -> str: + visits = list(location.visits.all()) + if not visits: + return '' + starts = [v.start_date for v in visits if v.start_date] + ends = [v.end_date for v in visits if v.end_date] + if not starts: + return '' + start = min(starts).date() if isinstance(min(starts), datetime) else min(starts) + end = max(ends).date() if ends and isinstance(max(ends), datetime) else (max(ends) if ends else None) + return _format_date_range(start, end) + + +def build_location_share_image(location: Location, aspect: str, public_url: str) -> bytes: + width, height = aspect_dimensions(aspect) + hero = _location_hero_image(location) + place = _location_place_name(location) + visit_range = _location_visit_range(location) + + subtitle_parts = [p for p in [place, visit_range] if p] + subtitle = ' · '.join(subtitle_parts) + + stats_parts = [] + if location.rating is not None: + stats_parts.append(f'★ {location.rating:g}') + if location.tags: + stats_parts.append(' · '.join(location.tags[:3])) + stats = ' · '.join(stats_parts) + + username = getattr(location.user, 'username', '') or '' + + return _compose_card( + width=width, + height=height, + hero=hero, + title=location.name, + subtitle=subtitle, + stats=stats, + username=username, + public_url=public_url, + ) + + +def build_collection_share_image(collection: Collection, aspect: str, public_url: str) -> bytes: + width, height = aspect_dimensions(aspect) + hero = _collection_hero_image(collection) + date_range = _format_date_range(collection.start_date, collection.end_date) + loc_count = collection.locations.count() + loc_label = 'location' if loc_count == 1 else 'locations' + + subtitle = date_range + stats = f'{loc_count} {loc_label}' if loc_count else '' + + top_locations = list(collection.locations.all()[:3]) + highlights = [loc.name for loc in top_locations] + + username = getattr(collection.user, 'username', '') or '' + + return _compose_card( + width=width, + height=height, + hero=hero, + title=collection.name, + subtitle=subtitle, + stats=stats, + username=username, + public_url=public_url, + highlights=highlights if highlights else None, + ) + + +def build_share_image( + obj: Union[Collection, Location], aspect: str, public_url: str +) -> bytes: + if isinstance(obj, Collection): + return build_collection_share_image(obj, aspect, public_url) + if isinstance(obj, Location): + return build_location_share_image(obj, aspect, public_url) + raise TypeError(f'Unsupported object type: {type(obj)}') + + +def share_image_filename(obj: Union[Collection, Location], aspect: str) -> str: + safe_name = re.sub(r'[^\w\s-]', '', obj.name).strip().replace(' ', '_') or 'share' + kind = 'collection' if isinstance(obj, Collection) else 'location' + return f'{safe_name}_{kind}_{aspect}.png' + + +def resolve_share_page_url(request, path: str) -> str: + """ + Build an absolute URL encoded in share-card QR codes. + Prefer the browser Origin/Referer (frontend) over backend PUBLIC_URL. + """ + from django.conf import settings + from urllib.parse import urlparse + + if not path.startswith('/'): + path = f'/{path}' + + for header in ('Origin', 'Referer'): + raw = request.headers.get(header) + if not raw: + continue + parsed = urlparse(raw) + if parsed.scheme and parsed.netloc: + return f'{parsed.scheme}://{parsed.netloc}{path}' + + base = getattr(settings, 'PUBLIC_URL', 'http://localhost:8000').rstrip('/') + return f'{base}{path}' + + +def get_collection_for_share(collection_id) -> Collection: + return get_collection_for_pdf(collection_id) diff --git a/backend/server/adventures/tests/test_collection_pdf.py b/backend/server/adventures/tests/test_collection_pdf.py new file mode 100644 index 00000000..4924e70e --- /dev/null +++ b/backend/server/adventures/tests/test_collection_pdf.py @@ -0,0 +1,98 @@ +from datetime import date, timedelta + +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from adventures.models import ( + Collection, + CollectionItineraryDay, + CollectionItineraryItem, + Location, + Note, +) +from adventures.services.collection_pdf import ( + build_collection_pdf, + pdf_filename_for_collection, +) + +User = get_user_model() + + +class CollectionPdfTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='pdfuser', password='testpass123') + + def test_build_collection_pdf_returns_valid_pdf(self): + start = date.today() + timedelta(days=30) + end = start + timedelta(days=2) + collection = Collection.objects.create( + user=self.user, + name='Alpine Loop', + description='**Pack layers** and good boots.', + start_date=start, + end_date=end, + ) + location = Location.objects.create( + user=self.user, + name='Trailhead Cafe', + location='123 Main St', + description='Morning coffee stop.', + ) + collection.locations.add(location) + + CollectionItineraryDay.objects.create( + collection=collection, + date=start, + name='Arrival day', + description='Settle in and explore.', + ) + + location_ct = ContentType.objects.get_for_model(Location) + CollectionItineraryItem.objects.create( + collection=collection, + content_type=location_ct, + object_id=location.id, + date=start, + order=0, + is_global=False, + ) + + note = Note.objects.create( + user=self.user, + collection=collection, + name='Reminders', + content='Bring passport copies.', + date=start, + ) + note_ct = ContentType.objects.get_for_model(Note) + CollectionItineraryItem.objects.create( + collection=collection, + content_type=note_ct, + object_id=note.id, + date=start + timedelta(days=1), + order=0, + is_global=False, + ) + + pdf_bytes = build_collection_pdf(collection) + + self.assertTrue(pdf_bytes.startswith(b'%PDF')) + self.assertGreater(len(pdf_bytes), 500) + self.assertEqual(pdf_filename_for_collection(collection), 'Alpine_Loop_itinerary.pdf') + + def test_folder_collection_pdf(self): + collection = Collection.objects.create( + user=self.user, + name='Ideas Folder', + description='Places to consider later.', + ) + location = Location.objects.create( + user=self.user, + name='Museum', + location='Downtown', + ) + collection.locations.add(location) + + pdf_bytes = build_collection_pdf(collection) + self.assertTrue(pdf_bytes.startswith(b'%PDF')) diff --git a/backend/server/adventures/tests/test_geo_points.py b/backend/server/adventures/tests/test_geo_points.py new file mode 100644 index 00000000..3618aa60 --- /dev/null +++ b/backend/server/adventures/tests/test_geo_points.py @@ -0,0 +1,109 @@ +from django.contrib.gis.geos import Point +from django.test import TestCase +from rest_framework.test import APIRequestFactory + +from adventures.models import Location, Transportation +from adventures.serializers import LocationSerializer, MapPinSerializer, TransportationSerializer +from adventures.utils.geo import make_point, point_to_lat_lon, has_coordinates +from users.models import CustomUser + + +class GeoUtilsTests(TestCase): + def test_make_point_and_point_to_lat_lon(self): + point = make_point(-73.968285, 40.785091) + self.assertIsNotNone(point) + self.assertEqual(point.srid, 4326) + lat, lon = point_to_lat_lon(point) + self.assertAlmostEqual(lat, 40.785091) + self.assertAlmostEqual(lon, -73.968285) + + def test_make_point_returns_none_for_incomplete_coords(self): + self.assertIsNone(make_point(None, 40.0)) + self.assertIsNone(make_point(-70.0, None)) + + +class LocationSerializerGeoTests(TestCase): + def setUp(self): + self.user = CustomUser.objects.create_user( + username='geo-user', + email='geo-user@example.com', + password='testpassword123', + ) + self.factory = APIRequestFactory() + self.request = self.factory.post('/') + self.request.user = self.user + + def _serializer(self): + return LocationSerializer(context={'request': self.request}) + + def test_create_persists_coordinates_from_lat_lon(self): + serializer = self._serializer() + location = serializer.create( + { + 'name': 'Geo Place', + 'latitude': 48.8566, + 'longitude': 2.3522, + 'is_public': False, + } + ) + location.refresh_from_db() + self.assertTrue(has_coordinates(location.coordinates)) + lat, lon = point_to_lat_lon(location.coordinates) + self.assertAlmostEqual(lat, 48.8566, places=4) + self.assertAlmostEqual(lon, 2.3522, places=4) + + def test_representation_exposes_lat_lon(self): + location = Location.objects.create( + user=self.user, + name='Stored', + coordinates=make_point(10.0, 20.0), + ) + data = self._serializer().to_representation(location) + self.assertAlmostEqual(data['latitude'], 20.0) + self.assertAlmostEqual(data['longitude'], 10.0) + + +class MapPinSerializerGeoTests(TestCase): + def setUp(self): + self.user = CustomUser.objects.create_user( + username='pin-user', + email='pin-user@example.com', + password='testpassword123', + ) + + def test_map_pin_serializer_lat_lon(self): + location = Location.objects.create( + user=self.user, + name='Pin', + coordinates=make_point(-122.4, 37.8), + ) + data = MapPinSerializer(location).data + self.assertAlmostEqual(data['latitude'], 37.8, places=4) + self.assertAlmostEqual(data['longitude'], -122.4, places=4) + + +class TransportationDistanceTests(TestCase): + def setUp(self): + self.user = CustomUser.objects.create_user( + username='transport-user', + email='transport-user@example.com', + password='testpassword123', + ) + self.factory = APIRequestFactory() + self.request = self.factory.get('/') + self.request.user = self.user + + def test_get_distance_uses_point_fields(self): + transport = Transportation.objects.create( + user=self.user, + type='car', + name='Trip', + origin=Point(-74.0, 40.7, srid=4326), + destination=Point(-118.25, 34.05, srid=4326), + ) + serializer = TransportationSerializer( + transport, context={'request': self.request} + ) + distance = serializer.get_distance(transport) + self.assertIsNotNone(distance) + self.assertGreater(distance, 1000) diff --git a/backend/server/adventures/tests/test_share_image.py b/backend/server/adventures/tests/test_share_image.py new file mode 100644 index 00000000..c7782eab --- /dev/null +++ b/backend/server/adventures/tests/test_share_image.py @@ -0,0 +1,166 @@ +from datetime import date, timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from PIL import Image as PILImage +import io + +from adventures.models import Collection, Location +from adventures.services.share_image import ( + ASPECTS, + build_collection_share_image, + build_location_share_image, + valid_aspect, +) + +User = get_user_model() + + +class ShareImageServiceTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='shareuser', password='testpass123') + self.other = User.objects.create_user(username='otheruser', password='testpass123') + + def _png_size(self, png_bytes: bytes) -> tuple[int, int]: + img = PILImage.open(io.BytesIO(png_bytes)) + return img.size + + def test_valid_aspect(self): + self.assertTrue(valid_aspect('square')) + self.assertTrue(valid_aspect('story')) + self.assertTrue(valid_aspect('landscape')) + self.assertFalse(valid_aspect('wide')) + + def test_build_collection_share_image_all_aspects(self): + start = date.today() + timedelta(days=10) + end = start + timedelta(days=3) + collection = Collection.objects.create( + user=self.user, + name='Coastal Road Trip', + start_date=start, + end_date=end, + is_public=True, + ) + location = Location.objects.create( + user=self.user, + name='Beach Stop', + location='Pacific Coast', + is_public=True, + ) + collection.locations.add(location) + + for aspect, (width, height) in ASPECTS.items(): + png = build_collection_share_image( + collection, aspect, f'http://example.com/collections/{collection.id}' + ) + self.assertTrue(png.startswith(b'\x89PNG')) + self.assertEqual(self._png_size(png), (width, height)) + + def test_build_location_share_image_all_aspects(self): + location = Location.objects.create( + user=self.user, + name='Summit View', + location='Rocky Mountains', + rating=4.5, + tags=['hiking', 'scenic'], + is_public=True, + ) + + for aspect, (width, height) in ASPECTS.items(): + png = build_location_share_image( + location, aspect, f'http://example.com/locations/{location.id}' + ) + self.assertTrue(png.startswith(b'\x89PNG')) + self.assertEqual(self._png_size(png), (width, height)) + + def test_collection_share_image_without_hero_image(self): + collection = Collection.objects.create( + user=self.user, + name='No Photo Trip', + is_public=True, + ) + png = build_collection_share_image( + collection, 'square', f'http://example.com/collections/{collection.id}' + ) + self.assertTrue(png.startswith(b'\x89PNG')) + self.assertEqual(self._png_size(png), ASPECTS['square']) + + +class ShareImageApiTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='apiuser', password='testpass123') + self.other = User.objects.create_user(username='apiother', password='testpass123') + self.client.login(username='apiuser', password='testpass123') + + self.public_collection = Collection.objects.create( + user=self.user, + name='Public Trip', + is_public=True, + ) + self.private_collection = Collection.objects.create( + user=self.user, + name='Private Trip', + is_public=False, + ) + self.public_location = Location.objects.create( + user=self.user, + name='Public Place', + is_public=True, + ) + self.private_location = Location.objects.create( + user=self.user, + name='Private Place', + is_public=False, + ) + + def test_owner_can_fetch_private_collection_share_image(self): + url = f'/api/collections/{self.private_collection.id}/share-image/square/' + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res['Content-Type'], 'image/png') + self.assertTrue(res.content.startswith(b'\x89PNG')) + + def test_anonymous_can_fetch_public_collection_share_image(self): + self.client.logout() + url = f'/api/collections/{self.public_collection.id}/share-image/landscape/' + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res['Content-Type'], 'image/png') + + def test_anonymous_cannot_fetch_private_collection_share_image(self): + self.client.logout() + url = f'/api/collections/{self.private_collection.id}/share-image/square/' + res = self.client.get(url) + self.assertIn(res.status_code, (403, 404)) + + def test_owner_can_fetch_private_location_share_image(self): + url = f'/api/locations/{self.private_location.id}/share-image/story/' + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res['Content-Type'], 'image/png') + + def test_anonymous_can_fetch_public_location_share_image(self): + self.client.logout() + url = f'/api/locations/{self.public_location.id}/share-image/square/' + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res['Content-Type'], 'image/png') + + def test_anonymous_cannot_fetch_private_location_share_image(self): + self.client.logout() + url = f'/api/locations/{self.private_location.id}/share-image/square/' + res = self.client.get(url) + self.assertIn(res.status_code, (403, 404)) + + def test_invalid_aspect_returns_400(self): + url = f'/api/collections/{self.public_collection.id}/share-image/wide/' + res = self.client.get(url) + self.assertEqual(res.status_code, 404) + + def test_download_disposition(self): + url = ( + f'/api/collections/{self.public_collection.id}/share-image/square/?download=1' + ) + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertIn('attachment', res['Content-Disposition']) diff --git a/backend/server/adventures/utils/geo.py b/backend/server/adventures/utils/geo.py new file mode 100644 index 00000000..3eb0a4ca --- /dev/null +++ b/backend/server/adventures/utils/geo.py @@ -0,0 +1,30 @@ +"""Shared helpers for GeoDjango PointField (SRID 4326: x=longitude, y=latitude).""" + +from __future__ import annotations + +from typing import Any + +from django.contrib.gis.geos import Point + +WGS84_SRID = 4326 + + +def make_point(lon: Any, lat: Any) -> Point | None: + """Build a WGS84 point from longitude/latitude, or None if either is missing.""" + if lon is None or lat is None: + return None + try: + return Point(float(lon), float(lat), srid=WGS84_SRID) + except (TypeError, ValueError): + return None + + +def point_to_lat_lon(point: Point | None) -> tuple[float | None, float | None]: + """Return (latitude, longitude) from a Point, or (None, None).""" + if not point: + return None, None + return point.y, point.x + + +def has_coordinates(point: Point | None) -> bool: + return point is not None diff --git a/backend/server/adventures/utils/serializer_geo_fields.py b/backend/server/adventures/utils/serializer_geo_fields.py new file mode 100644 index 00000000..9c88c9f7 --- /dev/null +++ b/backend/server/adventures/utils/serializer_geo_fields.py @@ -0,0 +1,226 @@ +"""DRF fields that expose latitude/longitude while storing GeoDjango PointFields.""" + +from __future__ import annotations + +from rest_framework import serializers + +from adventures.utils.geo import make_point, point_to_lat_lon + + +def _register_declared_fields(cls, field_map: dict[str, serializers.Field]) -> None: + """ + DRF only collects fields from Serializer subclasses into _declared_fields. + Mixins must register explicitly so Meta.fields names are not built from the model. + """ + declared = getattr(cls, '_declared_fields', None) + if declared is None: + return + for name, field in field_map.items(): + declared[name] = field + + +class CoordinateSerializerMixin: + """ + Mixin for serializers with a single `coordinates` PointField on the model. + + Declares writable `latitude` and `longitude` on the serializer; maps them to + `coordinates` in validated_data before create/update. + """ + + latitude = serializers.FloatField(required=False, allow_null=True) + longitude = serializers.FloatField(required=False, allow_null=True) + _point_field_name = 'coordinates' + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + _register_declared_fields( + cls, + { + 'latitude': serializers.FloatField(required=False, allow_null=True), + 'longitude': serializers.FloatField(required=False, allow_null=True), + }, + ) + + def _get_point_field_name(self) -> str: + return getattr(self, '_point_field_name', 'coordinates') + + def _pop_lat_lon(self, validated_data: dict) -> dict: + lat = validated_data.pop('latitude', serializers.empty) + lon = validated_data.pop('longitude', serializers.empty) + if lat is serializers.empty and lon is serializers.empty: + return validated_data + lat = None if lat is serializers.empty else lat + lon = None if lon is serializers.empty else lon + if lat is None and lon is None: + validated_data[self._get_point_field_name()] = None + else: + point = make_point(lon, lat) + if point is None and (lat is not None or lon is not None): + raise serializers.ValidationError( + 'Valid latitude and longitude are required together.' + ) + validated_data[self._get_point_field_name()] = point + return validated_data + + def create(self, validated_data): + validated_data = self._pop_lat_lon(validated_data) + return super().create(validated_data) + + def update(self, instance, validated_data): + validated_data = self._pop_lat_lon(validated_data) + return super().update(instance, validated_data) + + def to_representation(self, instance): + data = super().to_representation(instance) + point = getattr(instance, self._get_point_field_name(), None) + lat, lon = point_to_lat_lon(point) + data['latitude'] = lat + data['longitude'] = lon + return data + + +class TransportationCoordinateMixin: + """Maps origin/destination lat-lon API fields to PointFields on Transportation.""" + + origin_latitude = serializers.FloatField(required=False, allow_null=True) + origin_longitude = serializers.FloatField(required=False, allow_null=True) + destination_latitude = serializers.FloatField(required=False, allow_null=True) + destination_longitude = serializers.FloatField(required=False, allow_null=True) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + _register_declared_fields( + cls, + { + 'origin_latitude': serializers.FloatField(required=False, allow_null=True), + 'origin_longitude': serializers.FloatField(required=False, allow_null=True), + 'destination_latitude': serializers.FloatField(required=False, allow_null=True), + 'destination_longitude': serializers.FloatField(required=False, allow_null=True), + }, + ) + + def _apply_endpoint( + self, + validated_data: dict, + lat_key: str, + lon_key: str, + point_key: str, + ) -> dict: + lat = validated_data.pop(lat_key, serializers.empty) + lon = validated_data.pop(lon_key, serializers.empty) + if lat is serializers.empty and lon is serializers.empty: + return validated_data + lat = None if lat is serializers.empty else lat + lon = None if lon is serializers.empty else lon + if lat is None and lon is None: + validated_data[point_key] = None + else: + point = make_point(lon, lat) + if point is None and (lat is not None or lon is not None): + raise serializers.ValidationError( + {lat_key: 'Valid latitude and longitude are required together.'} + ) + validated_data[point_key] = point + return validated_data + + def _pop_transport_points(self, validated_data: dict) -> dict: + validated_data = self._apply_endpoint( + validated_data, 'origin_latitude', 'origin_longitude', 'origin' + ) + validated_data = self._apply_endpoint( + validated_data, + 'destination_latitude', + 'destination_longitude', + 'destination', + ) + return validated_data + + def create(self, validated_data): + validated_data = self._pop_transport_points(validated_data) + return super().create(validated_data) + + def update(self, instance, validated_data): + validated_data = self._pop_transport_points(validated_data) + return super().update(instance, validated_data) + + def to_representation(self, instance): + data = super().to_representation(instance) + o_lat, o_lon = point_to_lat_lon(getattr(instance, 'origin', None)) + d_lat, d_lon = point_to_lat_lon(getattr(instance, 'destination', None)) + data['origin_latitude'] = o_lat + data['origin_longitude'] = o_lon + data['destination_latitude'] = d_lat + data['destination_longitude'] = d_lon + return data + + +class ActivityCoordinateMixin: + """Maps start/end lat-lng API fields to PointFields on Activity.""" + + start_lat = serializers.FloatField(required=False, allow_null=True) + start_lng = serializers.FloatField(required=False, allow_null=True) + end_lat = serializers.FloatField(required=False, allow_null=True) + end_lng = serializers.FloatField(required=False, allow_null=True) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + _register_declared_fields( + cls, + { + 'start_lat': serializers.FloatField(required=False, allow_null=True), + 'start_lng': serializers.FloatField(required=False, allow_null=True), + 'end_lat': serializers.FloatField(required=False, allow_null=True), + 'end_lng': serializers.FloatField(required=False, allow_null=True), + }, + ) + + def _apply_activity_endpoint( + self, + validated_data: dict, + lat_key: str, + lng_key: str, + point_key: str, + ) -> dict: + lat = validated_data.pop(lat_key, serializers.empty) + lng = validated_data.pop(lng_key, serializers.empty) + if lat is serializers.empty and lng is serializers.empty: + return validated_data + lat = None if lat is serializers.empty else lat + lng = None if lng is serializers.empty else lng + if lat is None and lng is None: + validated_data[point_key] = None + else: + point = make_point(lng, lat) + if point is None and (lat is not None or lng is not None): + raise serializers.ValidationError( + {lat_key: 'Valid start/end coordinates are required together.'} + ) + validated_data[point_key] = point + return validated_data + + def _pop_activity_points(self, validated_data: dict) -> dict: + validated_data = self._apply_activity_endpoint( + validated_data, 'start_lat', 'start_lng', 'start_point' + ) + validated_data = self._apply_activity_endpoint( + validated_data, 'end_lat', 'end_lng', 'end_point' + ) + return validated_data + + def create(self, validated_data): + validated_data = self._pop_activity_points(validated_data) + return super().create(validated_data) + + def update(self, instance, validated_data): + validated_data = self._pop_activity_points(validated_data) + return super().update(instance, validated_data) + + def to_representation(self, instance): + data = super().to_representation(instance) + s_lat, s_lng = point_to_lat_lon(getattr(instance, 'start_point', None)) + e_lat, e_lng = point_to_lat_lon(getattr(instance, 'end_point', None)) + data['start_lat'] = s_lat + data['start_lng'] = s_lng + data['end_lat'] = e_lat + data['end_lng'] = e_lng + return data diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index 3c75d4be..6420ab5c 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -14,12 +14,28 @@ import os import json import zipfile import tempfile +import logging from adventures.models import Collection, Location, Transportation, Note, Checklist, ChecklistItem, 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 adventures.utils.geo import make_point, point_to_lat_lon from users.serializers import CustomUserDetailsSerializer as UserSerializer +from adventures.services.collection_pdf import ( + build_collection_pdf, + get_collection_for_pdf, + pdf_filename_for_collection, +) +from adventures.services.share_image import ( + build_share_image, + get_collection_for_share, + resolve_share_page_url, + share_image_filename, + valid_aspect, +) + +logger = logging.getLogger(__name__) class CollectionViewSet(viewsets.ModelViewSet): @@ -477,6 +493,53 @@ class CollectionViewSet(viewsets.ModelViewSet): return Response({"success": success_message}) + @action(detail=True, methods=['get'], url_path='export-pdf') + def export_pdf(self, request, pk=None): + """Export collection as a printable day-by-day itinerary PDF.""" + collection = self.get_object() + try: + collection = get_collection_for_pdf(collection.id) + pdf_bytes = build_collection_pdf(collection) + except Exception: + logger.exception('Failed to generate PDF for collection %s', collection.id) + return Response( + {'error': 'Failed to generate PDF. Please try again later.'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + filename = pdf_filename_for_collection(collection) + response = HttpResponse(pdf_bytes, content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + @action( + detail=True, + methods=['get'], + url_path=r'share-image/(?Psquare|story|landscape)', + ) + def share_image(self, request, pk=None, aspect=None): + """Generate a branded PNG share card for social media.""" + if not valid_aspect(aspect): + return Response({'error': 'Invalid aspect ratio.'}, status=status.HTTP_400_BAD_REQUEST) + + collection = self.get_object() + try: + collection = get_collection_for_share(collection.id) + page_url = resolve_share_page_url(request, f'/collections/{collection.id}') + png_bytes = build_share_image(collection, aspect, page_url) + except Exception: + logger.exception('Failed to generate share image for collection %s', collection.id) + return Response( + {'error': 'Failed to generate share image. Please try again later.'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + filename = share_image_filename(collection, aspect) + disposition = 'attachment' if request.query_params.get('download') else 'inline' + response = HttpResponse(png_bytes, content_type='image/png') + response['Content-Disposition'] = f'{disposition}; filename="{filename}"' + return response + @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.""" @@ -516,8 +579,12 @@ class CollectionViewSet(viewsets.ModelViewSet): '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, + 'longitude': ( + float(lon) if (lon := point_to_lat_lon(loc.coordinates)[1]) is not None else None + ), + 'latitude': ( + float(lat) if (lat := point_to_lat_lon(loc.coordinates)[0]) 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, @@ -712,7 +779,8 @@ class CollectionViewSet(viewsets.ModelViewSet): 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) + cand_lat, cand_lon = point_to_lat_lon(cand.coordinates) + close_coords = _coords_close(incoming_lat, incoming_lon, cand_lat, cand_lon) # 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: @@ -739,8 +807,7 @@ class CollectionViewSet(viewsets.ModelViewSet): 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, + coordinates=make_point(incoming_lon, incoming_lat), category=cat_obj, ) loc.collections.add(new_collection) @@ -933,10 +1000,8 @@ class CollectionViewSet(viewsets.ModelViewSet): end_timezone=item.end_timezone, flight_number=item.flight_number, from_location=item.from_location, - origin_latitude=item.origin_latitude, - origin_longitude=item.origin_longitude, - destination_latitude=item.destination_latitude, - destination_longitude=item.destination_longitude, + origin=item.origin, + destination=item.destination, start_code=item.start_code, end_code=item.end_code, to_location=item.to_location, @@ -972,8 +1037,7 @@ class CollectionViewSet(viewsets.ModelViewSet): timezone=item.timezone, reservation_number=item.reservation_number, price=item.price, - latitude=item.latitude, - longitude=item.longitude, + coordinates=item.coordinates, location=item.location, is_public=item.is_public, ) diff --git a/backend/server/adventures/views/import_export_view.py b/backend/server/adventures/views/import_export_view.py index 81e2aaaf..fa22155e 100644 --- a/backend/server/adventures/views/import_export_view.py +++ b/backend/server/adventures/views/import_export_view.py @@ -25,6 +25,7 @@ from adventures.models import ( CollectionItineraryItem ) from worldtravel.models import VisitedCity, VisitedRegion, City, Region, Country +from adventures.utils.geo import make_point, point_to_lat_lon User = get_user_model() @@ -364,8 +365,12 @@ class BackupViewSet(viewsets.ViewSet): 'price_currency': str(location.price.currency) if location.price else None, 'link': location.link, 'is_public': location.is_public, - 'longitude': str(location.longitude) if location.longitude else None, - 'latitude': str(location.latitude) if location.latitude else None, + 'longitude': ( + str(lon) if (lon := point_to_lat_lon(location.coordinates)[1]) is not None else None + ), + 'latitude': ( + str(lat) if (lat := point_to_lat_lon(location.coordinates)[0]) is not None else None + ), 'city': location.city_id, 'region': location.region_id, 'country': location.country_id, @@ -410,10 +415,26 @@ class BackupViewSet(viewsets.ViewSet): 'max_speed': float(activity.max_speed) if activity.max_speed else None, 'average_cadence': float(activity.average_cadence) if activity.average_cadence else None, 'calories': float(activity.calories) if activity.calories else None, - 'start_lat': float(activity.start_lat) if activity.start_lat else None, - 'start_lng': float(activity.start_lng) if activity.start_lng else None, - 'end_lat': float(activity.end_lat) if activity.end_lat else None, - 'end_lng': float(activity.end_lng) if activity.end_lng else None, + 'start_lat': ( + float(s_lat) + if (s_lat := point_to_lat_lon(activity.start_point)[0]) is not None + else None + ), + 'start_lng': ( + float(s_lng) + if (s_lng := point_to_lat_lon(activity.start_point)[1]) is not None + else None + ), + 'end_lat': ( + float(e_lat) + if (e_lat := point_to_lat_lon(activity.end_point)[0]) is not None + else None + ), + 'end_lng': ( + float(e_lng) + if (e_lng := point_to_lat_lon(activity.end_point)[1]) is not None + else None + ), 'external_service_id': activity.external_service_id, 'trail_name': activity.trail.name if activity.trail else None, # Link by trail name 'gpx_filename': None @@ -482,10 +503,26 @@ class BackupViewSet(viewsets.ViewSet): 'end_timezone': transport.end_timezone, 'flight_number': transport.flight_number, 'from_location': transport.from_location, - 'origin_latitude': str(transport.origin_latitude) if transport.origin_latitude else None, - 'origin_longitude': str(transport.origin_longitude) if transport.origin_longitude else None, - 'destination_latitude': str(transport.destination_latitude) if transport.destination_latitude else None, - 'destination_longitude': str(transport.destination_longitude) if transport.destination_longitude else None, + 'origin_latitude': ( + str(o_lat) + if (o_lat := point_to_lat_lon(transport.origin)[0]) is not None + else None + ), + 'origin_longitude': ( + str(o_lon) + if (o_lon := point_to_lat_lon(transport.origin)[1]) is not None + else None + ), + 'destination_latitude': ( + str(d_lat) + if (d_lat := point_to_lat_lon(transport.destination)[0]) is not None + else None + ), + 'destination_longitude': ( + str(d_lon) + if (d_lon := point_to_lat_lon(transport.destination)[1]) is not None + else None + ), 'to_location': transport.to_location, 'is_public': transport.is_public, 'collection_export_id': collection_export_id, @@ -574,8 +611,12 @@ class BackupViewSet(viewsets.ViewSet): 'reservation_number': lodging.reservation_number, 'price': str(lodging.price.amount) if lodging.price else None, 'price_currency': str(lodging.price.currency) if lodging.price else None, - 'latitude': str(lodging.latitude) if lodging.latitude else None, - 'longitude': str(lodging.longitude) if lodging.longitude else None, + 'latitude': ( + str(lat) if (lat := point_to_lat_lon(lodging.coordinates)[0]) is not None else None + ), + 'longitude': ( + str(lon) if (lon := point_to_lat_lon(lodging.coordinates)[1]) is not None else None + ), 'location': lodging.location, 'is_public': lodging.is_public, 'collection_export_id': collection_export_id, @@ -1032,8 +1073,7 @@ class BackupViewSet(viewsets.ViewSet): price_currency=location_price_currency, link=adv_data.get('link'), is_public=adv_data.get('is_public', False), - longitude=adv_data.get('longitude'), - latitude=adv_data.get('latitude'), + coordinates=make_point(adv_data.get('longitude'), adv_data.get('latitude')), city=city, region=region, country=country, @@ -1107,10 +1147,12 @@ class BackupViewSet(viewsets.ViewSet): max_speed=activity_data.get('max_speed'), average_cadence=activity_data.get('average_cadence'), calories=activity_data.get('calories'), - start_lat=activity_data.get('start_lat'), - start_lng=activity_data.get('start_lng'), - end_lat=activity_data.get('end_lat'), - end_lng=activity_data.get('end_lng'), + start_point=make_point( + activity_data.get('start_lng'), activity_data.get('start_lat') + ), + end_point=make_point( + activity_data.get('end_lng'), activity_data.get('end_lat') + ), external_service_id=activity_data.get('external_service_id') ) @@ -1198,10 +1240,13 @@ class BackupViewSet(viewsets.ViewSet): end_timezone=trans_data.get('end_timezone'), flight_number=trans_data.get('flight_number'), from_location=trans_data.get('from_location'), - origin_latitude=trans_data.get('origin_latitude'), - origin_longitude=trans_data.get('origin_longitude'), - destination_latitude=trans_data.get('destination_latitude'), - destination_longitude=trans_data.get('destination_longitude'), + origin=make_point( + trans_data.get('origin_longitude'), trans_data.get('origin_latitude') + ), + destination=make_point( + trans_data.get('destination_longitude'), + trans_data.get('destination_latitude'), + ), to_location=trans_data.get('to_location'), is_public=trans_data.get('is_public', False), collection=collection @@ -1331,8 +1376,7 @@ class BackupViewSet(viewsets.ViewSet): reservation_number=lodg_data.get('reservation_number'), price=lodging_price, price_currency=lodging_price_currency, - latitude=lodg_data.get('latitude'), - longitude=lodg_data.get('longitude'), + coordinates=make_point(lodg_data.get('longitude'), lodg_data.get('latitude')), location=lodg_data.get('location'), is_public=lodg_data.get('is_public', False), collection=collection diff --git a/backend/server/adventures/views/location_view.py b/backend/server/adventures/views/location_view.py index ce91a219..c3206e66 100644 --- a/backend/server/adventures/views/location_view.py +++ b/backend/server/adventures/views/location_view.py @@ -5,6 +5,8 @@ from django.core.exceptions import PermissionDenied from django.core.files.base import ContentFile from django.db.models import Q, Max, Prefetch from django.db.models.functions import Lower +from django.http import HttpResponse +from django.conf import settings from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response @@ -19,7 +21,15 @@ from adventures.serializers import ( ) from adventures.utils import pagination from adventures.throttling import ExternalGeocodeThrottle, ExternalSunTimesThrottle +from adventures.utils.geo import point_to_lat_lon from adventures.services.geocoding.reverse import reverse_geocode as reverse_geocode_service +from adventures.services.share_image import ( + build_share_image, + get_location_for_share, + resolve_share_page_url, + share_image_filename, + valid_aspect, +) from worldtravel.models import City, Country, Region from .location_image_view import import_remote_images_for_object from .quick_add_utils import ( @@ -466,8 +476,7 @@ class LocationViewSet(viewsets.ModelViewSet): location=original.location, tags=list(original.tags) if original.tags else None, is_public=False, - longitude=original.longitude, - latitude=original.latitude, + coordinates=original.coordinates, city=original.city, region=original.region, country=original.country, @@ -537,6 +546,34 @@ class LocationViewSet(viewsets.ModelViewSet): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + @action( + detail=True, + methods=['get'], + url_path=r'share-image/(?Psquare|story|landscape)', + ) + def share_image(self, request, pk=None, aspect=None): + """Generate a branded PNG share card for social media.""" + if not valid_aspect(aspect): + return Response({'error': 'Invalid aspect ratio.'}, status=status.HTTP_400_BAD_REQUEST) + + location = self.get_object() + try: + location = get_location_for_share(location.id) + page_url = resolve_share_page_url(request, f'/locations/{location.id}') + png_bytes = build_share_image(location, aspect, page_url) + except Exception: + logger.exception('Failed to generate share image for location %s', location.id) + return Response( + {'error': 'Failed to generate share image. Please try again later.'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + filename = share_image_filename(location, aspect) + disposition = 'attachment' if request.query_params.get('download') else 'inline' + response = HttpResponse(png_bytes, content_type='image/png') + response['Content-Disposition'] = f'{disposition}; filename="{filename}"' + return response + # view to return location name and lat/lon for all locations a user owns for the golobal map @action(detail=False, methods=['get'], url_path='pins') def map_locations(self, request): @@ -776,7 +813,8 @@ class LocationViewSet(viewsets.ModelViewSet): from adventures.services.sun.times import get_sun_times_for_visits try: - return get_sun_times_for_visits(adventure.latitude, adventure.longitude, visits) + latitude, longitude = point_to_lat_lon(adventure.coordinates) + return get_sun_times_for_visits(latitude, longitude, visits) except Exception: return [] diff --git a/backend/server/billing/admin.py b/backend/server/billing/admin.py index 92761120..241426d9 100644 --- a/backend/server/billing/admin.py +++ b/backend/server/billing/admin.py @@ -13,6 +13,30 @@ if settings.CLOUD_MODE: "trial_ends_at", "current_period_ends_at", "cancel_at_period_end", + "created_at", ) search_fields = ("user__username", "stripe_customer_id", "stripe_subscription_id") list_filter = ("status", "cancel_at_period_end") + autocomplete_fields = ("user",) + readonly_fields = ("created_at", "updated_at") + date_hierarchy = "trial_ends_at" + + fieldsets = ( + (None, {"fields": ("user", "status", "cancel_at_period_end")}), + ( + "Stripe", + { + "fields": ("stripe_customer_id", "stripe_subscription_id"), + }, + ), + ( + "Billing periods", + { + "fields": ("trial_ends_at", "current_period_ends_at"), + }, + ), + ( + "Metadata", + {"classes": ("collapse",), "fields": ("created_at", "updated_at")}, + ), + ) diff --git a/backend/server/integrations/admin.py b/backend/server/integrations/admin.py index c749b4b2..78b277e7 100644 --- a/backend/server/integrations/admin.py +++ b/backend/server/integrations/admin.py @@ -1,17 +1,69 @@ from django.contrib import admin from allauth.account.decorators import secure_admin_login -from .models import ImmichIntegration, StravaToken, WandererIntegration +from integrations.models import ImmichIntegration, StravaToken, WandererIntegration -admin.autodiscover() admin.site.login = secure_admin_login(admin.site.login) - @admin.register(WandererIntegration) class WandererIntegrationAdmin(admin.ModelAdmin): list_display = ('user', 'server_url', 'id') - exclude = ('api_key',) + search_fields = ('user__username', 'server_url') + autocomplete_fields = ('user',) + readonly_fields = ('id',) + + fieldsets = ( + (None, {'fields': ('user', 'server_url', 'id')}), + ( + 'Credentials', + { + 'classes': ('collapse',), + 'fields': ('api_key',), + 'description': 'API key is not shown in list views. Expand to view or rotate.', + }, + ), + ) -admin.site.register(ImmichIntegration) -admin.site.register(StravaToken) +@admin.register(ImmichIntegration) +class ImmichIntegrationAdmin(admin.ModelAdmin): + list_display = ('user', 'server_url', 'copy_locally', 'id') + list_filter = ('copy_locally',) + search_fields = ('user__username', 'server_url') + autocomplete_fields = ('user',) + readonly_fields = ('id',) + + fieldsets = ( + (None, {'fields': ('user', 'server_url', 'copy_locally', 'id')}), + ( + 'Credentials', + {'classes': ('collapse',), 'fields': ('api_key',)}, + ), + ) + + +@admin.register(StravaToken) +class StravaTokenAdmin(admin.ModelAdmin): + list_display = ( + 'user', + 'athlete_id', + 'expires_at', + 'scope', + 'updated_at', + ) + list_filter = ('scope',) + search_fields = ('user__username', 'athlete_id') + autocomplete_fields = ('user',) + readonly_fields = ('updated_at',) + + fieldsets = ( + (None, {'fields': ('user', 'athlete_id', 'scope', 'expires_at', 'updated_at')}), + ( + 'OAuth tokens', + { + 'classes': ('collapse',), + 'fields': ('access_token', 'refresh_token'), + 'description': 'OAuth tokens are stored for Strava sync.', + }, + ), + ) diff --git a/backend/server/main/admin_utils.py b/backend/server/main/admin_utils.py new file mode 100644 index 00000000..c2fc4b74 --- /dev/null +++ b/backend/server/main/admin_utils.py @@ -0,0 +1,91 @@ +"""Shared Django admin helpers for AdventureLog.""" + +from __future__ import annotations + +from django.contrib import admin +from django.contrib.gis import admin as gis_admin +from django.utils.html import format_html + +from adventures.utils.geo import point_to_lat_lon +from main.utils import build_media_url + +TIMESTAMP_READONLY = ('created_at', 'updated_at') +UUID_READONLY = ('id',) + + +def coordinate_display(point, precision: int = 5) -> str: + lat, lon = point_to_lat_lon(point) + if lat is None or lon is None: + return '—' + return f'{lat:.{precision}f}, {lon:.{precision}f}' + + +def admin_image_preview(image_field, width: int = 100, height: int = 100): + if not image_field: + return '—' + image_url = build_media_url(image_field.name) + if not image_url: + return '—' + return format_html( + '', + image_url, + width, + height, + ) + + +class TimestampedAdminMixin: + readonly_fields = TIMESTAMP_READONLY + + +class AdventureLogGeoModelAdmin(gis_admin.GISModelAdmin): + """GISModelAdmin with OSM map widgets for PointFields.""" + + gis_widget_kwargs = { + 'attrs': { + 'default_lon': 0, + 'default_lat': 30, + 'default_zoom': 2, + }, + } + + +class CoordinatesDisplayMixin: + coordinates_field = 'coordinates' + + @admin.display(description='Coordinates') + def display_coordinates(self, obj): + return coordinate_display(getattr(obj, self.coordinates_field, None)) + + @admin.display(description='Lat') + def display_latitude(self, obj): + lat, _ = point_to_lat_lon(getattr(obj, self.coordinates_field, None)) + return lat + + @admin.display(description='Lon') + def display_longitude(self, obj): + _, lon = point_to_lat_lon(getattr(obj, self.coordinates_field, None)) + return lon + + +class UserOwnedAdminMixin: + list_filter = ('user',) + autocomplete_fields = ('user',) + search_fields = ('name',) + + +def generic_object_admin_link(obj, attr: str = 'item') -> str: + linked = getattr(obj, attr, None) + if not linked: + return '—' + try: + from django.urls import reverse + + ct = obj.content_type + admin_url = reverse( + f'admin:{ct.app_label}_{ct.model}_change', + args=[obj.object_id], + ) + return format_html('{}', admin_url, str(linked)) + except Exception: + return str(linked) diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index 15f35170..aefd1e2e 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -33,4 +33,5 @@ gpxpy==1.6.2 pymemcache==4.0.0 legacy-cgi==2.6.3 requests>=2.31.0 -stripe==11.3.0 \ No newline at end of file +stripe==11.3.0 +reportlab>=4.2.0,<5 \ No newline at end of file diff --git a/backend/server/users/admin.py b/backend/server/users/admin.py index c8871d28..0c4d13bd 100644 --- a/backend/server/users/admin.py +++ b/backend/server/users/admin.py @@ -1,15 +1,115 @@ from django.contrib import admin -from allauth.account.decorators import secure_admin_login +from django.contrib.auth.admin import UserAdmin from django.contrib.sessions.models import Session -from users.models import APIKey +from allauth.account.decorators import secure_admin_login + +from main.admin_utils import TIMESTAMP_READONLY, admin_image_preview +from users.models import APIKey, CustomUser -admin.autodiscover() admin.site.login = secure_admin_login(admin.site.login) -class SessionAdmin(admin.ModelAdmin): - def _session_data(self, obj): - return obj.get_decoded() - list_display = ['session_key', '_session_data', 'expire_date'] -admin.site.register(APIKey) -admin.site.register(Session, SessionAdmin) \ No newline at end of file +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + model = CustomUser + list_display = ( + 'username', + 'email', + 'is_staff', + 'is_active', + 'public_profile', + 'measurement_system', + 'default_currency', + 'map_style', + 'profile_preview', + 'date_joined', + ) + list_filter = ( + 'is_staff', + 'is_active', + 'is_superuser', + 'public_profile', + 'measurement_system', + 'default_currency', + 'map_style', + ) + search_fields = ('username', 'email', 'first_name', 'last_name', 'uuid') + ordering = ('username',) + readonly_fields = ('uuid', 'profile_preview_large', 'last_login', 'date_joined') + filter_horizontal = ('groups', 'user_permissions') + + fieldsets = UserAdmin.fieldsets + ( + ( + 'AdventureLog profile', + { + 'fields': ( + 'profile_pic', + 'profile_preview_large', + 'uuid', + 'public_profile', + 'disable_password', + 'measurement_system', + 'default_currency', + 'map_style', + ), + }, + ), + ) + + add_fieldsets = UserAdmin.add_fieldsets + + @admin.display(description='Avatar') + def profile_preview(self, obj): + return admin_image_preview(obj.profile_pic, 40, 40) + + @admin.display(description='Profile picture') + def profile_preview_large(self, obj): + return admin_image_preview(obj.profile_pic, 120, 120) + + +@admin.register(APIKey) +class APIKeyAdmin(admin.ModelAdmin): + list_display = ( + 'name', + 'user', + 'key_prefix', + 'created_at', + 'last_used_at', + ) + list_filter = ('user',) + search_fields = ('name', 'key_prefix', 'user__username') + autocomplete_fields = ('user',) + readonly_fields = ('id', 'key_prefix', 'key_hash', 'created_at', 'last_used_at') + ordering = ('-created_at',) + + fieldsets = ( + (None, {'fields': ('user', 'name')}), + ( + 'Key metadata', + { + 'fields': ('id', 'key_prefix', 'key_hash', 'created_at', 'last_used_at'), + 'description': 'Plaintext API keys are only shown once at creation and are never stored.', + }, + ), + ) + + def has_add_permission(self, request): + return False + + +@admin.register(Session) +class SessionAdmin(admin.ModelAdmin): + list_display = ('session_key', 'expire_date', 'session_preview') + search_fields = ('session_key',) + readonly_fields = ('session_key', 'expire_date', 'session_data_decoded') + ordering = ('-expire_date',) + + @admin.display(description='Session data') + def session_preview(self, obj): + data = obj.get_decoded() + preview = str(data) + return preview[:80] + ('…' if len(preview) > 80 else '') + + @admin.display(description='Decoded session') + def session_data_decoded(self, obj): + return obj.get_decoded() diff --git a/backend/server/worldtravel/admin.py b/backend/server/worldtravel/admin.py index f0d74d62..4ae8a31c 100644 --- a/backend/server/worldtravel/admin.py +++ b/backend/server/worldtravel/admin.py @@ -1,5 +1,161 @@ from django.contrib import admin from allauth.account.decorators import secure_admin_login -admin.autodiscover() -admin.site.login = secure_admin_login(admin.site.login) \ No newline at end of file +from main.admin_utils import ( + AdventureLogGeoModelAdmin, + CoordinatesDisplayMixin, + UUID_READONLY, + coordinate_display, +) +from worldtravel.models import City, Country, Region, VisitedCity, VisitedRegion + +admin.site.login = secure_admin_login(admin.site.login) + + +@admin.register(Country) +class CountryAdmin(CoordinatesDisplayMixin, AdventureLogGeoModelAdmin): + list_display = ( + 'name', + 'country_code', + 'subregion', + 'capital', + 'display_coordinates', + 'region_count', + ) + list_filter = ('subregion',) + search_fields = ('name', 'country_code', 'capital', 'subregion') + ordering = ('name',) + readonly_fields = UUID_READONLY + ('display_coordinates_readonly',) + + fieldsets = ( + (None, {'fields': ('name', 'country_code', 'subregion', 'capital')}), + ( + 'Geography', + { + 'fields': ( + 'coordinates', + 'display_coordinates_readonly', + ), + 'description': 'Set the country centroid on the map (longitude = x, latitude = y).', + }, + ), + ) + + @admin.display(description='Regions') + def region_count(self, obj): + return Region.objects.filter(country=obj).count() + + @admin.display(description='Coordinates (lat, lon)') + def display_coordinates_readonly(self, obj): + return coordinate_display(obj.coordinates) + + +@admin.register(Region) +class RegionAdmin(CoordinatesDisplayMixin, AdventureLogGeoModelAdmin): + list_display = ( + 'name', + 'country', + 'display_coordinates', + 'city_count', + 'visit_count', + ) + list_filter = ('country', 'country__subregion') + search_fields = ('name', 'id', 'country__name', 'country__country_code') + autocomplete_fields = ('country',) + ordering = ('country__name', 'name') + readonly_fields = UUID_READONLY + ('display_coordinates_readonly',) + + fieldsets = ( + (None, {'fields': ('id', 'name', 'country')}), + ( + 'Geography', + {'fields': ('coordinates', 'display_coordinates_readonly')}, + ), + ) + + @admin.display(description='Cities') + def city_count(self, obj): + return City.objects.filter(region=obj).count() + + @admin.display(description='Visits') + def visit_count(self, obj): + return VisitedRegion.objects.filter(region=obj).count() + + @admin.display(description='Coordinates (lat, lon)') + def display_coordinates_readonly(self, obj): + return coordinate_display(obj.coordinates) + + +@admin.register(City) +class CityAdmin(CoordinatesDisplayMixin, AdventureLogGeoModelAdmin): + list_display = ( + 'name', + 'region', + 'country_name', + 'display_coordinates', + ) + list_filter = ('region__country', 'region') + search_fields = ('name', 'id', 'region__name', 'region__country__name') + autocomplete_fields = ('region',) + ordering = ('region__country__name', 'region__name', 'name') + readonly_fields = UUID_READONLY + ('display_coordinates_readonly',) + + fieldsets = ( + (None, {'fields': ('id', 'name', 'region')}), + ( + 'Geography', + {'fields': ('coordinates', 'display_coordinates_readonly')}, + ), + ) + + @admin.display(description='Country') + def country_name(self, obj): + return obj.region.country.name if obj.region_id else '—' + + @admin.display(description='Coordinates (lat, lon)') + def display_coordinates_readonly(self, obj): + return coordinate_display(obj.coordinates) + + +@admin.register(VisitedRegion) +class VisitedRegionAdmin(admin.ModelAdmin): + list_display = ('user', 'region', 'country_name', 'display_coordinates') + list_filter = ('region__country', 'user') + search_fields = ( + 'user__username', + 'region__name', + 'region__country__name', + ) + autocomplete_fields = ('user', 'region') + + @admin.display(description='Country') + def country_name(self, obj): + return obj.region.country.name + + @admin.display(description='Region coordinates') + def display_coordinates(self, obj): + return coordinate_display(obj.region.coordinates) + + +@admin.register(VisitedCity) +class VisitedCityAdmin(admin.ModelAdmin): + list_display = ('user', 'city', 'region_name', 'country_name', 'display_coordinates') + list_filter = ('city__region__country', 'user') + search_fields = ( + 'user__username', + 'city__name', + 'city__region__name', + ) + autocomplete_fields = ('user', 'city') + + @admin.display(description='Region') + def region_name(self, obj): + return obj.city.region.name + + @admin.display(description='Country') + def country_name(self, obj): + return obj.city.region.country.name + + @admin.display(description='City coordinates') + def display_coordinates(self, obj): + return coordinate_display(obj.city.coordinates) diff --git a/backend/server/worldtravel/management/commands/download-countries.py b/backend/server/worldtravel/management/commands/download-countries.py index fc5ce614..b63f74f3 100644 --- a/backend/server/worldtravel/management/commands/download-countries.py +++ b/backend/server/worldtravel/management/commands/download-countries.py @@ -12,9 +12,16 @@ from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.conf import settings +from adventures.utils.geo import make_point COUNTRY_REGION_JSON_VERSION = settings.COUNTRY_REGION_JSON_VERSION + +def coordinates_from_lon_lat(longitude, latitude): + if longitude is None or latitude is None: + return None + return make_point(longitude, latitude) + def saveCountryFlag(country_code): # For standards, use the lowercase country_code country_code = country_code.lower() @@ -220,7 +227,7 @@ class Command(BaseCommand): existing_countries = { c.country_code: c for c in Country.objects.filter(country_code__in=country_codes_in_batch) - .only('country_code', 'name', 'subregion', 'capital', 'longitude', 'latitude') + .only('country_code', 'name', 'subregion', 'capital', 'coordinates') } for row in rows: @@ -232,8 +239,7 @@ class Command(BaseCommand): country_obj.name = name country_obj.subregion = subregion country_obj.capital = capital - country_obj.longitude = longitude - country_obj.latitude = latitude + country_obj.coordinates = coordinates_from_lon_lat(longitude, latitude) countries_to_update.append(country_obj) else: countries_to_create.append(Country( @@ -241,8 +247,7 @@ class Command(BaseCommand): name=name, subregion=subregion, capital=capital, - longitude=longitude, - latitude=latitude + coordinates=coordinates_from_lon_lat(longitude, latitude), )) processed += 1 @@ -257,7 +262,7 @@ class Command(BaseCommand): with transaction.atomic(): Country.objects.bulk_update( countries_to_update, - ['name', 'subregion', 'capital', 'longitude', 'latitude'], + ['name', 'subregion', 'capital', 'coordinates'], batch_size=batch_size ) countries_to_update.clear() @@ -274,7 +279,7 @@ class Command(BaseCommand): with transaction.atomic(): Country.objects.bulk_update( countries_to_update, - ['name', 'subregion', 'capital', 'longitude', 'latitude'], + ['name', 'subregion', 'capital', 'coordinates'], batch_size=batch_size ) @@ -302,7 +307,7 @@ class Command(BaseCommand): r.id: r for r in Region.objects.filter(id__in=region_ids_in_batch) .select_related('country') - .only('id', 'name', 'country', 'longitude', 'latitude') + .only('id', 'name', 'country', 'coordinates') } for row in rows: @@ -317,16 +322,14 @@ class Command(BaseCommand): region_obj = existing_regions[region_id] region_obj.name = name region_obj.country = country_obj - region_obj.longitude = longitude - region_obj.latitude = latitude + region_obj.coordinates = coordinates_from_lon_lat(longitude, latitude) regions_to_update.append(region_obj) else: regions_to_create.append(Region( id=region_id, name=name, country=country_obj, - longitude=longitude, - latitude=latitude + coordinates=coordinates_from_lon_lat(longitude, latitude), )) processed += 1 @@ -341,7 +344,7 @@ class Command(BaseCommand): with transaction.atomic(): Region.objects.bulk_update( regions_to_update, - ['name', 'country', 'longitude', 'latitude'], + ['name', 'country', 'coordinates'], batch_size=batch_size ) regions_to_update.clear() @@ -358,7 +361,7 @@ class Command(BaseCommand): with transaction.atomic(): Region.objects.bulk_update( regions_to_update, - ['name', 'country', 'longitude', 'latitude'], + ['name', 'country', 'coordinates'], batch_size=batch_size ) @@ -395,21 +398,19 @@ class Command(BaseCommand): continue if city_id in existing_city_ids: - # For updates, just store the data - we'll do bulk update by raw SQL - cities_to_update.append({ - 'id': city_id, - 'name': name, - 'region_id': region_obj.id, - 'longitude': longitude, - 'latitude': latitude - }) + city_obj = City( + id=city_id, + name=name, + region=region_obj, + coordinates=coordinates_from_lon_lat(longitude, latitude), + ) + cities_to_update.append(city_obj) else: cities_to_create.append(City( id=city_id, name=name, region=region_obj, - longitude=longitude, - latitude=latitude + coordinates=coordinates_from_lon_lat(longitude, latitude), )) processed += 1 @@ -420,9 +421,13 @@ class Command(BaseCommand): City.objects.bulk_create(cities_to_create, batch_size=batch_size, ignore_conflicts=True) cities_to_create.clear() - # Flush update batch with raw SQL for speed if cities_to_update: - self._bulk_update_cities_raw(cities_to_update) + with transaction.atomic(): + City.objects.bulk_update( + cities_to_update, + ['name', 'region', 'coordinates'], + batch_size=batch_size, + ) cities_to_update.clear() if processed % 5000 == 0: @@ -434,59 +439,15 @@ class Command(BaseCommand): with transaction.atomic(): City.objects.bulk_create(cities_to_create, batch_size=batch_size, ignore_conflicts=True) if cities_to_update: - self._bulk_update_cities_raw(cities_to_update) + with transaction.atomic(): + City.objects.bulk_update( + cities_to_update, + ['name', 'region', 'coordinates'], + batch_size=batch_size, + ) self.stdout.write(f'✓ Cities complete: {processed} processed') - def _bulk_update_cities_raw(self, cities_data): - """Fast bulk update using raw SQL""" - if not cities_data: - return - - from django.db import connection - - with connection.cursor() as cursor: - # Build the SQL for bulk update - # Using CASE statements for efficient bulk updates - when_clauses_name = [] - when_clauses_region = [] - when_clauses_lng = [] - when_clauses_lat = [] - city_ids = [] - - for city in cities_data: - city_id = city['id'] - city_ids.append(city_id) - when_clauses_name.append(f"WHEN id = %s THEN %s") - when_clauses_region.append(f"WHEN id = %s THEN %s") - when_clauses_lng.append(f"WHEN id = %s THEN %s") - when_clauses_lat.append(f"WHEN id = %s THEN %s") - - # Build parameters list - params = [] - for city in cities_data: - params.extend([city['id'], city['name']]) # for name - for city in cities_data: - params.extend([city['id'], city['region_id']]) # for region_id - for city in cities_data: - params.extend([city['id'], city['longitude']]) # for longitude - for city in cities_data: - params.extend([city['id'], city['latitude']]) # for latitude - params.extend(city_ids) # for WHERE clause - - # Execute the bulk update - sql = f""" - UPDATE worldtravel_city - SET - name = CASE {' '.join(when_clauses_name)} END, - region_id = CASE {' '.join(when_clauses_region)} END, - longitude = CASE {' '.join(when_clauses_lng)} END, - latitude = CASE {' '.join(when_clauses_lat)} END - WHERE id IN ({','.join(['%s'] * len(city_ids))}) - """ - - cursor.execute(sql, params) - def _cleanup_obsolete_records(self, temp_conn): """Clean up obsolete records using temporary database""" # Get IDs from temp database to avoid loading large lists into memory diff --git a/backend/server/worldtravel/migrations/0019_pointfield_coordinates.py b/backend/server/worldtravel/migrations/0019_pointfield_coordinates.py new file mode 100644 index 00000000..74695f9a --- /dev/null +++ b/backend/server/worldtravel/migrations/0019_pointfield_coordinates.py @@ -0,0 +1,45 @@ +# Generated manually for PostGIS PointField migration + +from django.contrib.gis.geos import Point +from django.db import migrations +import django.contrib.gis.db.models.fields + + +def forwards_fill_coordinates(apps, schema_editor): + for model_name in ('Country', 'Region', 'City'): + Model = apps.get_model('worldtravel', model_name) + for row in Model.objects.exclude(latitude__isnull=True).exclude(longitude__isnull=True).iterator(): + row.coordinates = Point(float(row.longitude), float(row.latitude), srid=4326) + row.save(update_fields=['coordinates']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0018_rename_user_id_visitedcity_user'), + ] + + operations = [ + migrations.AddField( + model_name='country', + name='coordinates', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + migrations.AddField( + model_name='region', + name='coordinates', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + migrations.AddField( + model_name='city', + name='coordinates', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + migrations.RunPython(forwards_fill_coordinates, migrations.RunPython.noop), + migrations.RemoveField(model_name='country', name='latitude'), + migrations.RemoveField(model_name='country', name='longitude'), + migrations.RemoveField(model_name='region', name='latitude'), + migrations.RemoveField(model_name='region', name='longitude'), + migrations.RemoveField(model_name='city', name='latitude'), + migrations.RemoveField(model_name='city', name='longitude'), + ] diff --git a/backend/server/worldtravel/models.py b/backend/server/worldtravel/models.py index e4f69e6d..e7385a14 100644 --- a/backend/server/worldtravel/models.py +++ b/backend/server/worldtravel/models.py @@ -2,12 +2,14 @@ from django.db import models from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.contrib.gis.db import models as gis_models +from adventures.utils.geo import point_to_lat_lon User = get_user_model() default_user = 1 # Replace with an actual user ID + class Country(models.Model): id = models.AutoField(primary_key=True) @@ -15,13 +17,22 @@ class Country(models.Model): country_code = models.CharField(max_length=2, unique=True) #iso2 code subregion = models.CharField(max_length=100, blank=True, null=True) capital = models.CharField(max_length=100, blank=True, null=True) - longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) - latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + coordinates = gis_models.PointField(srid=4326, null=True, blank=True) class Meta: verbose_name = "Country" verbose_name_plural = "Countries" + @property + def latitude(self): + lat, _ = point_to_lat_lon(self.coordinates) + return lat + + @property + def longitude(self): + _, lon = point_to_lat_lon(self.coordinates) + return lon + def __str__(self): return self.name @@ -29,8 +40,17 @@ class Region(models.Model): id = models.CharField(primary_key=True) name = models.CharField(max_length=100) country = models.ForeignKey(Country, on_delete=models.CASCADE) - longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) - latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + coordinates = gis_models.PointField(srid=4326, null=True, blank=True) + + @property + def latitude(self): + lat, _ = point_to_lat_lon(self.coordinates) + return lat + + @property + def longitude(self): + _, lon = point_to_lat_lon(self.coordinates) + return lon def __str__(self): return self.name @@ -39,12 +59,21 @@ class City(models.Model): id = models.CharField(primary_key=True) name = models.CharField(max_length=100) region = models.ForeignKey(Region, on_delete=models.CASCADE) - longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) - latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + coordinates = gis_models.PointField(srid=4326, null=True, blank=True) class Meta: verbose_name_plural = "Cities" + @property + def latitude(self): + lat, _ = point_to_lat_lon(self.coordinates) + return lat + + @property + def longitude(self): + _, lon = point_to_lat_lon(self.coordinates) + return lon + def __str__(self): return self.name @@ -77,4 +106,4 @@ class VisitedCity(models.Model): super().save(*args, **kwargs) class Meta: - verbose_name_plural = "Visited Cities" \ No newline at end of file + verbose_name_plural = "Visited Cities" diff --git a/backend/server/worldtravel/serializers.py b/backend/server/worldtravel/serializers.py index 8df5d3fd..3c5ccae3 100644 --- a/backend/server/worldtravel/serializers.py +++ b/backend/server/worldtravel/serializers.py @@ -1,9 +1,23 @@ from .models import Country, Region, VisitedRegion, City, VisitedCity from rest_framework import serializers from main.utils import CustomModelSerializer, build_media_url +from adventures.utils.geo import point_to_lat_lon -class CountrySerializer(serializers.ModelSerializer): +class ReadOnlyLatLonMixin: + latitude = serializers.SerializerMethodField() + longitude = serializers.SerializerMethodField() + + def get_latitude(self, obj): + lat, _ = point_to_lat_lon(obj.coordinates) + return lat + + def get_longitude(self, obj): + _, lon = point_to_lat_lon(obj.coordinates) + return lon + + +class CountrySerializer(ReadOnlyLatLonMixin, serializers.ModelSerializer): flag_url = serializers.SerializerMethodField() num_regions = serializers.SerializerMethodField() num_visits = serializers.SerializerMethodField() @@ -27,33 +41,33 @@ class CountrySerializer(serializers.ModelSerializer): class Meta: model = Country - fields = '__all__' + exclude = ['coordinates'] read_only_fields = ['id', 'name', 'country_code', 'subregion', 'flag_url', 'num_regions', 'num_visits', 'longitude', 'latitude', 'capital'] -class RegionSerializer(serializers.ModelSerializer): +class RegionSerializer(ReadOnlyLatLonMixin, serializers.ModelSerializer): num_cities = serializers.SerializerMethodField() country_name = serializers.CharField(source='country.name', read_only=True) class Meta: model = Region - fields = '__all__' + exclude = ['coordinates'] read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude', 'num_cities', 'country_name'] def get_num_cities(self, obj): return City.objects.filter(region=obj).count() -class CitySerializer(serializers.ModelSerializer): +class CitySerializer(ReadOnlyLatLonMixin, serializers.ModelSerializer): region_name = serializers.CharField(source='region.name', read_only=True) country_name = serializers.CharField(source='region.country.name', read_only=True ) class Meta: model = City - fields = '__all__' + exclude = ['coordinates'] read_only_fields = ['id', 'name', 'region', 'longitude', 'latitude', 'region_name', 'country_name'] class VisitedRegionSerializer(CustomModelSerializer): - longitude = serializers.DecimalField(source='region.longitude', max_digits=9, decimal_places=6, read_only=True) - latitude = serializers.DecimalField(source='region.latitude', max_digits=9, decimal_places=6, read_only=True) + longitude = serializers.FloatField(source='region.longitude', read_only=True) + latitude = serializers.FloatField(source='region.latitude', read_only=True) name = serializers.CharField(source='region.name', read_only=True) class Meta: @@ -62,8 +76,8 @@ class VisitedRegionSerializer(CustomModelSerializer): read_only_fields = ['user', 'id', 'longitude', 'latitude', 'name'] class VisitedCitySerializer(CustomModelSerializer): - longitude = serializers.DecimalField(source='city.longitude', max_digits=9, decimal_places=6, read_only=True) - latitude = serializers.DecimalField(source='city.latitude', max_digits=9, decimal_places=6, read_only=True) + longitude = serializers.FloatField(source='city.longitude', read_only=True) + latitude = serializers.FloatField(source='city.latitude', read_only=True) name = serializers.CharField(source='city.name', read_only=True) class Meta: diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py index baa604c7..7f1e4dfd 100644 --- a/backend/server/worldtravel/views.py +++ b/backend/server/worldtravel/views.py @@ -82,9 +82,9 @@ class CountryViewSet(viewsets.ReadOnlyModelViewSet): adventures = Location.objects.filter(user=request.user.id, type='visited') count = 0 for adventure in adventures: - if adventure.latitude is not None and adventure.longitude is not None: + if adventure.coordinates: try: - point = Point(float(adventure.longitude), float(adventure.latitude), srid=4326) + point = adventure.coordinates region = Region.objects.filter(geometry__contains=point).first() if region: if not VisitedRegion.objects.filter(user=request.user.id, region=region).exists(): diff --git a/frontend/src/lib/components/SocialShareModal.svelte b/frontend/src/lib/components/SocialShareModal.svelte new file mode 100644 index 00000000..b5fc7ee0 --- /dev/null +++ b/frontend/src/lib/components/SocialShareModal.svelte @@ -0,0 +1,260 @@ + + + + + diff --git a/frontend/src/lib/components/cards/CollectionCard.svelte b/frontend/src/lib/components/cards/CollectionCard.svelte index 66955818..13ec775d 100644 --- a/frontend/src/lib/components/cards/CollectionCard.svelte +++ b/frontend/src/lib/components/cards/CollectionCard.svelte @@ -20,6 +20,7 @@ import TrashCan from '~icons/mdi/trashcan'; import DeleteWarning from '../DeleteWarning.svelte'; import ShareModal from '../ShareModal.svelte'; + import SocialShareModal from '../SocialShareModal.svelte'; import CardCarousel from '../CardCarousel.svelte'; import ExitRun from '~icons/mdi/exit-run'; import Eye from '~icons/mdi/eye'; @@ -27,8 +28,10 @@ import Check from '~icons/mdi/check'; import MapMarker from '~icons/mdi/map-marker-multiple'; import LinkIcon from '~icons/mdi/link'; - import DownloadIcon from '~icons/mdi/download'; - import ContentCopy from '~icons/mdi/content-copy'; +import DownloadIcon from '~icons/mdi/download'; +import FilePdfBox from '~icons/mdi/file-pdf-box'; +import ContentCopy from '~icons/mdi/content-copy'; +import ImageOutline from '~icons/mdi/image-outline'; const dispatch = createEventDispatcher(); @@ -36,6 +39,7 @@ export let linkedCollectionList: string[] | null = null; export let user: User | null; let isShareModalOpen: boolean = false; + let isSocialShareModalOpen: boolean = false; let copied: boolean = false; async function copyLink() { @@ -77,26 +81,50 @@ dispatch('edit', collection); } - async function exportCollectionZip() { + async function downloadCollectionBlob( + url: string, + filename: string, + successKey: string, + failedKey: string + ) { try { - const res = await fetch(`/api/collections/${collection.id}/export`); + const res = await fetch(url); if (!res.ok) { - addToast('error', $t('adventures.export_failed') || 'Export failed'); + addToast('error', $t(failedKey) || 'Export failed'); return; } const blob = await res.blob(); - const url = URL.createObjectURL(blob); + const objectUrl = URL.createObjectURL(blob); const a = document.createElement('a'); - a.href = url; - a.download = `collection-${String(collection.name).replace(/\s+/g, '_')}.zip`; + a.href = objectUrl; + a.download = filename; a.click(); - URL.revokeObjectURL(url); - addToast('success', $t('adventures.export_success') || 'Exported collection'); + URL.revokeObjectURL(objectUrl); + addToast('success', $t(successKey) || 'Exported collection'); } catch (e) { - addToast('error', $t('adventures.export_failed') || 'Export failed'); + addToast('error', $t(failedKey) || 'Export failed'); } } + async function exportCollectionZip() { + await downloadCollectionBlob( + `/api/collections/${collection.id}/export`, + `collection-${String(collection.name).replace(/\s+/g, '_')}.zip`, + 'adventures.export_success', + 'adventures.export_failed' + ); + } + + async function exportCollectionPdf() { + const safeName = String(collection.name).replace(/[^\w\s-]/g, '').replace(/\s+/g, '_') || 'collection'; + await downloadCollectionBlob( + `/api/collections/${collection.id}/export-pdf/`, + `${safeName}_itinerary.pdf`, + 'adventures.export_pdf_success', + 'adventures.export_pdf_failed' + ); + } + async function archiveCollection(is_archived: boolean) { console.log(JSON.stringify({ is_archived: is_archived })); let res = await fetch(`/api/collections/${collection.id}/`, { @@ -176,6 +204,16 @@ (isShareModalOpen = false)} /> {/if} +{#if isSocialShareModalOpen} + (isSocialShareModalOpen = false)} + /> +{/if} +
@@ -360,6 +398,15 @@ {$t('adventures.share')} +
  • + +
  • {#if collection.is_public}
  • {/if} +
  • + +
  • +
  • +
  • + {/if} + {#if itineraryItem && itineraryItem.id}
    {#if !itineraryItem.is_global} diff --git a/frontend/src/lib/shareMeta.server.ts b/frontend/src/lib/shareMeta.server.ts new file mode 100644 index 00000000..ab9f3050 --- /dev/null +++ b/frontend/src/lib/shareMeta.server.ts @@ -0,0 +1,115 @@ +const MARKDOWN_PATTERNS: Array<[RegExp, string]> = [ + [/!\[.*?\]\(.*?\)/g, ''], + [/\[([^\]]+)\]\([^)]+\)/g, '$1'], + [/#{1,6}\s*/g, ''], + [/\*\*([^*]+)\*\*/g, '$1'], + [/\*([^*]+)\*/g, '$1'], + [/`([^`]+)`/g, '$1'], +]; + +export function stripMarkdown(value: string | null | undefined, maxLength = 200): string { + if (!value) return ''; + let text = String(value); + for (const [pattern, replacement] of MARKDOWN_PATTERNS) { + text = text.replace(pattern, replacement); + } + text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + text = text.replace(/\n{3,}/g, '\n\n').trim(); + if (text.length > maxLength) { + return `${text.slice(0, maxLength - 3)}...`; + } + return text; +} + +export type ShareMeta = { + title: string; + description: string; + imageUrl: string; + pageUrl: string; +}; + +function formatShortDate(value: string | null | undefined): string { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); +} + +export function buildCollectionShareMeta( + collection: { + id: string; + name: string; + description?: string | null; + is_public?: boolean; + start_date?: string | null; + end_date?: string | null; + locations?: unknown[] | null; + }, + origin: string +): ShareMeta | null { + if (!collection?.is_public) return null; + + const locCount = collection.locations?.length ?? 0; + let description = stripMarkdown(collection.description); + if (!description) { + const parts: string[] = []; + if (locCount) { + parts.push(`${locCount} location${locCount === 1 ? '' : 's'}`); + } + const start = formatShortDate(collection.start_date); + const end = formatShortDate(collection.end_date); + if (start && end && start !== end) { + parts.push(`${start} – ${end}`); + } else if (start) { + parts.push(start); + } + description = parts.join(' · ') || collection.name; + } + + return { + title: collection.name, + description, + imageUrl: `${origin}/api/collections/${collection.id}/share-image/landscape/`, + pageUrl: `${origin}/collections/${collection.id}`, + }; +} + +export function buildLocationShareMeta( + location: { + id: string; + name: string; + description?: string | null; + is_public?: boolean; + location?: string | null; + rating?: number | null; + tags?: string[] | null; + city?: { name?: string } | null; + region?: { name?: string } | null; + country?: { name?: string } | null; + }, + origin: string +): ShareMeta | null { + if (!location?.is_public) return null; + + let description = stripMarkdown(location.description); + if (!description) { + const parts: string[] = []; + const place = + location.city?.name || + location.region?.name || + location.country?.name || + location.location || + ''; + if (place) parts.push(place); + if (location.rating != null) parts.push(`Rated ${location.rating}`); + if (location.tags?.length) parts.push(location.tags.slice(0, 3).join(', ')); + description = parts.join(' · ') || location.name; + } + + return { + title: location.name, + description, + imageUrl: `${origin}/api/locations/${location.id}/share-image/landscape/`, + pageUrl: `${origin}/locations/${location.id}`, + }; +} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index c9900dde..be1d92f6 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -490,6 +490,9 @@ "import_failed": "Import Failed", "import_from_file": "Import from file", "export_zip": "Export ZIP", + "export_pdf": "Download PDF", + "export_pdf_success": "Collection PDF downloaded", + "export_pdf_failed": "Failed to download collection PDF", "export_failed": "Export failed", "export_success": "Exported collection", "location_actions": "Location actions", @@ -968,6 +971,24 @@ "invite_failed": "Invite Failed", "invite_sent": "Invite Sent" }, + "social_share": { + "share_externally": "Share Externally", + "modal_desc": "Create a shareable image for social media.", + "aspect_square": "Square", + "aspect_story": "Story", + "aspect_landscape": "Landscape", + "download_image": "Download Image", + "copy_image": "Copy Image", + "copy_image_success": "Image copied to clipboard", + "copy_image_failed": "Failed to copy image", + "share_native": "Share…", + "share_native_failed": "Sharing failed", + "preview_loading": "Generating preview…", + "share_image_failed": "Failed to generate share image", + "download_success": "Share image downloaded", + "private_notice_collection": "This collection is not public. The QR code and link only work for you and people you've shared the collection with.", + "private_notice_location": "This location is not public. The QR code and link only work for you and users with access." + }, "languages": {}, "profile": { "member_since": "Member since", diff --git a/frontend/src/routes/collections/[id]/+page.server.ts b/frontend/src/routes/collections/[id]/+page.server.ts index 02e659ce..1ecce247 100644 --- a/frontend/src/routes/collections/[id]/+page.server.ts +++ b/frontend/src/routes/collections/[id]/+page.server.ts @@ -2,6 +2,7 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; import type { Location, Collection } from '$lib/types'; +import { buildCollectionShareMeta } from '$lib/shareMeta.server'; const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; export const load = (async (event) => { @@ -17,7 +18,8 @@ export const load = (async (event) => { return { props: { adventure: null - } + }, + shareMeta: null }; } else { let collection = (await request.json()) as Collection; @@ -25,7 +27,8 @@ export const load = (async (event) => { return { props: { adventure: collection - } + }, + shareMeta: buildCollectionShareMeta(collection, event.url.origin) }; } }) satisfies PageServerLoad; diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index e184a225..58704c2e 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -31,6 +31,9 @@ import Lightbulb from '~icons/mdi/lightbulb'; import ChartBar from '~icons/mdi/chart-bar'; import Plus from '~icons/mdi/plus'; + import FilePdfBox from '~icons/mdi/file-pdf-box'; + import ImageOutline from '~icons/mdi/image-outline'; + import SocialShareModal from '$lib/components/SocialShareModal.svelte'; import { addToast } from '$lib/toasts'; import NoteModal from '$lib/components/NoteModal.svelte'; import ChecklistModal from '$lib/components/ChecklistModal.svelte'; @@ -255,6 +258,37 @@ // Enforce recommendations visibility only for owner/shared users $: availableViews.recommendations = !!canModifyCollection; + let isExportingPdf = false; + let isSocialShareModalOpen = false; + + async function exportCollectionPdf() { + if (!collection || isExportingPdf) return; + isExportingPdf = true; + try { + const res = await fetch(`/api/collections/${collection.id}/export-pdf/`); + if (!res.ok) { + addToast('error', $t('adventures.export_pdf_failed')); + return; + } + const blob = await res.blob(); + const safeName = + String(collection.name) + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '_') || 'collection'; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${safeName}_itinerary.pdf`; + a.click(); + URL.revokeObjectURL(url); + addToast('success', $t('adventures.export_pdf_success')); + } catch { + addToast('error', $t('adventures.export_pdf_failed')); + } finally { + isExportingPdf = false; + } + } + // Build calendar events from collection visits type TimezoneMode = 'event' | 'local'; @@ -816,6 +850,16 @@ /> {/if} +{#if isSocialShareModalOpen && collection} + (isSocialShareModalOpen = false)} + /> +{/if} + {#if isLocationLinkModalOpen && collection}
    -
    +
    {#if availableViews.all}
    +
    + + +
    @@ -1609,5 +1671,18 @@ {collection && collection.name ? `${collection.name}` : 'Collection'} - + {#if data.shareMeta} + + + + + + + + + + + {:else} + + {/if} diff --git a/frontend/src/routes/locations/[id]/+page.server.ts b/frontend/src/routes/locations/[id]/+page.server.ts index 194770bb..2c3dc9bb 100644 --- a/frontend/src/routes/locations/[id]/+page.server.ts +++ b/frontend/src/routes/locations/[id]/+page.server.ts @@ -1,6 +1,7 @@ import type { PageServerLoad } from './$types'; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; import type { AdditionalLocation, Location, Collection } from '$lib/types'; +import { buildLocationShareMeta } from '$lib/shareMeta.server'; const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; export const load = (async (event) => { @@ -16,7 +17,8 @@ export const load = (async (event) => { return { props: { adventure: null - } + }, + shareMeta: null }; } else { let adventure = (await request.json()) as AdditionalLocation; @@ -24,7 +26,8 @@ export const load = (async (event) => { return { props: { adventure - } + }, + shareMeta: buildLocationShareMeta(adventure, event.url.origin) }; } }) satisfies PageServerLoad; diff --git a/frontend/src/routes/locations/[id]/+page.svelte b/frontend/src/routes/locations/[id]/+page.svelte index 7d6fe6f2..ba37eca7 100644 --- a/frontend/src/routes/locations/[id]/+page.svelte +++ b/frontend/src/routes/locations/[id]/+page.svelte @@ -29,6 +29,8 @@ import ExternalMapLinks from '$lib/components/shared/ExternalMapLinks.svelte'; import MapFloatingControls from '$lib/components/map/MapFloatingControls.svelte'; import MapTrackLayerControls from '$lib/components/map/MapTrackLayerControls.svelte'; + import ImageOutline from '~icons/mdi/image-outline'; + import SocialShareModal from '$lib/components/SocialShareModal.svelte'; const renderMarkdown = (markdown: string) => { return marked(markdown) as string; @@ -57,6 +59,7 @@ let notFound: boolean = false; let isEditModalOpen: boolean = false; + let isSocialShareModalOpen: boolean = false; let adventure_images: { image: string; adventure: AdditionalLocation | null }[] = []; let modalInitialIndex: number = 0; let isImageModalOpen: boolean = false; @@ -210,6 +213,16 @@ /> {/if} +{#if isSocialShareModalOpen && adventure} + (isSocialShareModalOpen = false)} + /> +{/if} + {#if !adventure && !notFound}
    @@ -244,6 +257,18 @@ {$t('adventures.edit_location')} +
  • + +