Add PDF generation for collections and enhance admin functionality

- Implemented PDF generation for collections, allowing users to create printable itineraries.
- Added support for Noto fonts to ensure proper rendering of text and emojis in PDFs.
- Enhanced admin interfaces for achievements and adventures, improving usability and data management.
- Introduced new fields and methods in models and serializers to support geolocation features and improve data handling.
- Updated migrations to transition from latitude/longitude fields to a unified PointField for geographic data.
- Added tests to validate PDF generation functionality and ensure reliability.
This commit is contained in:
Sean Morley
2026-06-03 17:39:24 -04:00
parent 3e08d2c306
commit db9f4a66f6
40 changed files with 4207 additions and 376 deletions

View File

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

View File

@@ -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)
@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',)

View File

@@ -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'<img src="{image_url}" width="100px" height="100px"')
else:
return
class ContentImageImageAdmin(admin.ModelAdmin):
list_display = ('user', 'image_display')
def image_display(self, obj):
if obj.image:
image_url = build_media_url(obj.image.name)
if image_url:
return mark_safe(f'<img src="{image_url}" width="100px" height="100px"')
else:
return
@admin.display(description='Start (lat, lon)')
def start_coords_readonly(self, obj):
return coordinate_display(obj.start_point)
@admin.display(description='End (lat, lon)')
def end_coords_readonly(self, obj):
return coordinate_display(obj.end_point)
class VisitAdmin(admin.ModelAdmin):
list_display = ('location', 'start_date', 'end_date', 'notes')
list_filter = ('start_date', 'end_date')
search_fields = ('notes',)
@admin.register(ContentImage)
class ContentImageAdmin(admin.ModelAdmin):
list_display = (
'id',
'user',
'content_type',
'content_link',
'image_preview',
'is_primary',
'immich_id',
)
list_filter = ('content_type', 'is_primary', 'user')
search_fields = ('immich_id', 'user__username', 'object_id')
autocomplete_fields = ('user', 'content_type')
readonly_fields = UUID_READONLY + ('image_preview_large', 'content_link')
fieldsets = (
(None, {'fields': ('user', 'content_type', 'object_id', 'content_link')}),
('Image', {'fields': ('image', 'image_preview_large', 'immich_id', 'is_primary')}),
)
@admin.display(description='Attached to')
def content_link(self, obj):
return generic_object_admin_link(obj, attr='content_object')
@admin.display(description='Preview')
def image_preview(self, obj):
return admin_image_preview(obj.image, 48, 48)
@admin.display(description='Preview')
def image_preview_large(self, obj):
return admin_image_preview(obj.image, 200, 200)
def image_display(self, obj):
if obj.image: # Ensure this field matches your model's image field
image_url = build_media_url(obj.image.name)
if image_url:
return mark_safe(f'<img src="{image_url}" width="100px" height="100px"')
else:
return
@admin.register(ContentAttachment)
class ContentAttachmentAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'content_type', 'content_link', 'file')
list_filter = ('content_type', 'user')
search_fields = ('name', 'user__username', 'object_id')
autocomplete_fields = ('user', 'content_type')
readonly_fields = UUID_READONLY + ('content_link',)
image_display.short_description = 'Image Preview'
@admin.display(description='Attached to')
def content_link(self, obj):
return generic_object_admin_link(obj, attr='content_object')
class CollectionInviteAdmin(admin.ModelAdmin):
@admin.register(CollectionInvite)
class CollectionInviteAdmin(TimestampedAdminMixin, admin.ModelAdmin):
list_display = ('collection', 'invited_user', 'created_at')
search_fields = ('collection__name', 'invited_user__username')
readonly_fields = ('created_at',)
autocomplete_fields = ('collection', 'invited_user')
readonly_fields = UUID_READONLY + TIMESTAMP_READONLY
def invited_user(self, obj):
return obj.invited_user.username if obj.invited_user else 'N/A'
invited_user.short_description = 'Invited User'
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'display_name', 'icon')
search_fields = ('name', 'display_name')
class CollectionAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'is_public')
class ActivityAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'visit__location', 'sport_type', 'distance', 'elevation_gain', 'moving_time')
@admin.register(CollectionItineraryItem)
class CollectionItineraryItemAdmin(admin.ModelAdmin):
list_display = ('collection', 'content_type', 'object_link', 'date', 'order')
search_fields = ('collection__name', 'content_type__model')
list_filter = ('content_type', 'date')
raw_id_fields = ('collection',)
readonly_fields = ('created_at',)
list_display = (
'collection',
'content_type',
'object_link',
'date',
'is_global',
'order',
'created_at',
)
list_filter = ('content_type', 'is_global', 'collection')
search_fields = ('collection__name',)
autocomplete_fields = ('collection', 'content_type')
readonly_fields = ('created_at',) + UUID_READONLY + ('object_link',)
ordering = ('collection', 'date', 'order')
fieldsets = (
(None, {'fields': ('collection', 'content_type', 'object_id', 'object_link')}),
('Placement', {'fields': ('date', 'is_global', 'order')}),
('Metadata', {'classes': ('collapse',), 'fields': ('created_at',)}),
)
def object_link(self, obj):
"""
Display the generic related object; link to its admin change page if registered.
"""
linked_obj = obj.item
if not linked_obj:
return ""
try:
ct = obj.content_type
app_label = ct.app_label
model = ct.model
admin_url = reverse('admin:%s_%s_change' % (app_label, model), args=[obj.object_id])
return format_html('<a href="{}">{}</a>', 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'

View File

@@ -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'),
]

View File

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

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -0,0 +1,2 @@
Noto Sans and Noto Emoji are licensed under the SIL Open Font License 1.1.
See https://openfontlicense.org/

View File

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

View File

@@ -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'<b>{title}</b>{f" <font color=\"#64748b\">{category}</font>" 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 href="{escape_xml(location.link)}">Link</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'<b>{title}</b> <font color="#64748b">({pdf_markup(transport.type)})</font>', 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 href="{escape_xml(transport.link)}">Link</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'<b>{title}</b> <font color="#64748b">({pdf_markup(lodging.type)})</font>',
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 href="{escape_xml(lodging.link)}">Link</link>')
if extras:
flowables.append(Paragraph(' · '.join(extras), styles['ItemMeta']))
return flowables
def _note_paragraphs(note: Note, styles) -> List:
flowables = []
flowables.append(Paragraph(f'<b>{pdf_markup(note.name or "Note")}</b>', 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'<link href="{escape_xml(link)}">{pdf_markup(link)}</link>',
styles['ItemMeta'],
)
)
return flowables
def _checklist_paragraphs(checklist: Checklist, styles, _body_font: str, _emoji_font: str | None) -> List:
flowables = []
flowables.append(Paragraph(f'<b>{pdf_markup(checklist.name or "Checklist")}</b>', 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'<font color="#0d9488"><b>{order}.</b></font> '
f'<font color="#64748b">{_item_type_label(model).upper()}</font>',
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'<link href="{escape_xml(collection.link)}">{pdf_markup(collection.link)}</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'<b>Overnight:</b> {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'

View File

@@ -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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
)
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'<font face="{font}">{escaped}</font>')
else:
parts.append(escaped)
return ''.join(parts).replace('\n', '<br/>')
def apply_fonts_to_styles(styles: dict, body_font: str) -> None:
for style in styles.values():
style.fontName = body_font

View File

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

View File

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

View File

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

View File

@@ -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'])

View File

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

View File

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

View File

@@ -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/(?P<aspect>square|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,
)

View File

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

View File

@@ -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/(?P<aspect>square|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 []

View File

@@ -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")},
),
)

View File

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

View File

@@ -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(
'<img src="{}" width="{}" height="{}" style="object-fit:cover;border-radius:4px;" />',
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('<a href="{}">{}</a>', admin_url, str(linked))
except Exception:
return str(linked)

View File

@@ -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
stripe==11.3.0
reportlab>=4.2.0,<5

View File

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

View File

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

View File

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

View File

@@ -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'),
]

View File

@@ -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"
verbose_name_plural = "Visited Cities"

View File

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

View File

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

View File

@@ -0,0 +1,260 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { addToast } from '$lib/toasts';
import { copyToClipboard } from '$lib/index';
import Share from '~icons/mdi/share-variant';
import Clear from '~icons/mdi/close';
import Download from '~icons/mdi/download';
import ContentCopy from '~icons/mdi/content-copy';
import LinkIcon from '~icons/mdi/link';
import Check from '~icons/mdi/check';
import ImageOutline from '~icons/mdi/image-outline';
const dispatch = createEventDispatcher();
export let type: 'collection' | 'location';
export let id: string;
export let name: string;
export let isPublic: boolean = false;
type Aspect = 'square' | 'story' | 'landscape';
const aspects: Aspect[] = ['square', 'story', 'landscape'];
let modal: HTMLDialogElement;
let selectedAspect: Aspect = 'square';
let previewLoading = true;
let previewError = false;
let copiedLink = false;
let canNativeShare = false;
$: publicUrl =
typeof window !== 'undefined'
? `${window.location.origin}/${type === 'collection' ? 'collections' : 'locations'}/${id}`
: '';
$: imageUrl = `/api/${type === 'collection' ? 'collections' : 'locations'}/${id}/share-image/${selectedAspect}/`;
$: previewUrl = `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}t=${selectedAspect}`;
onMount(() => {
modal = document.getElementById('social_share_modal') as HTMLDialogElement;
modal?.showModal();
canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
});
function close() {
dispatch('close');
modal?.close();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') close();
}
function handlePreviewLoad() {
previewLoading = false;
previewError = false;
}
function handlePreviewError() {
previewLoading = false;
previewError = true;
}
function aspectLabel(aspect: Aspect): string {
if (aspect === 'square') return $t('social_share.aspect_square');
if (aspect === 'story') return $t('social_share.aspect_story');
return $t('social_share.aspect_landscape');
}
function previewMaxHeight(aspect: Aspect): string {
if (aspect === 'story') return '420px';
if (aspect === 'landscape') return '220px';
return '320px';
}
async function fetchImageBlob(): Promise<Blob | null> {
try {
const res = await fetch(`${imageUrl}?download=1`);
if (!res.ok) {
addToast('error', $t('social_share.share_image_failed'));
return null;
}
return await res.blob();
} catch {
addToast('error', $t('social_share.share_image_failed'));
return null;
}
}
function safeFilename(): string {
const safeName = String(name).replace(/[^\w\s-]/g, '').replace(/\s+/g, '_') || 'share';
return `${safeName}_${type}_${selectedAspect}.png`;
}
async function downloadImage() {
const blob = await fetchImageBlob();
if (!blob) return;
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = safeFilename();
a.click();
URL.revokeObjectURL(objectUrl);
addToast('success', $t('social_share.download_success'));
}
async function copyImage() {
const blob = await fetchImageBlob();
if (!blob) return;
try {
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
throw new Error('Clipboard API unavailable');
}
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
addToast('success', $t('social_share.copy_image_success'));
} catch {
addToast('error', $t('social_share.copy_image_failed'));
}
}
async function copyLink() {
try {
await copyToClipboard(publicUrl);
copiedLink = true;
setTimeout(() => (copiedLink = false), 2000);
} catch {
addToast('error', $t('adventures.copy_failed'));
}
}
async function nativeShare() {
const blob = await fetchImageBlob();
if (!blob) return;
try {
const file = new File([blob], safeFilename(), { type: 'image/png' });
await navigator.share({
title: name,
text: name,
url: isPublic ? publicUrl : undefined,
files: [file]
});
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return;
addToast('error', $t('social_share.share_native_failed'));
}
}
function selectAspect(aspect: Aspect) {
selectedAspect = aspect;
previewLoading = true;
previewError = false;
}
</script>
<dialog id="social_share_modal" class="modal backdrop-blur-sm">
<div
class="modal-box w-11/12 max-w-3xl p-6 space-y-5"
role="dialog"
tabindex="0"
on:keydown={handleKeydown}
>
<div class="flex items-center justify-between border-b border-base-300 pb-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<Share class="w-6 h-6 text-primary" />
</div>
<div>
<h3 class="text-2xl font-bold text-primary">
{$t('social_share.share_externally')}
</h3>
<p class="text-sm text-base-content/60">{$t('social_share.modal_desc')}</p>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-square" on:click={close} aria-label={$t('about.close')}>
<Clear class="w-5 h-5" />
</button>
</div>
{#if !isPublic}
<div class="alert alert-info text-sm py-3">
<span>
{$t(
type === 'collection'
? 'social_share.private_notice_collection'
: 'social_share.private_notice_location'
)}
</span>
</div>
{/if}
<div role="tablist" class="tabs tabs-boxed bg-base-200">
{#each aspects as aspect}
<button
role="tab"
class="tab {selectedAspect === aspect ? 'tab-active' : ''}"
on:click={() => selectAspect(aspect)}
>
{aspectLabel(aspect)}
</button>
{/each}
</div>
<div
class="relative flex items-center justify-center bg-base-200 rounded-xl overflow-hidden min-h-[180px]"
style="max-height: {previewMaxHeight(selectedAspect)}"
>
{#if previewLoading && !previewError}
<div class="flex flex-col items-center gap-2 py-10 text-base-content/60">
<span class="loading loading-spinner loading-md"></span>
<span class="text-sm">{$t('social_share.preview_loading')}</span>
</div>
{/if}
{#if previewError}
<div class="flex flex-col items-center gap-2 py-10 text-error">
<ImageOutline class="w-10 h-10 opacity-60" />
<span class="text-sm">{$t('social_share.share_image_failed')}</span>
</div>
{/if}
<img
src={previewUrl}
alt={name}
class="max-w-full object-contain {previewLoading ? 'hidden' : ''} {previewError
? 'hidden'
: ''}"
style="max-height: {previewMaxHeight(selectedAspect)}"
on:load={handlePreviewLoad}
on:error={handlePreviewError}
/>
</div>
<div class="flex flex-wrap gap-2">
<button class="btn btn-primary gap-2" on:click={downloadImage}>
<Download class="w-4 h-4" />
{$t('social_share.download_image')}
</button>
<button class="btn btn-neutral gap-2" on:click={copyImage}>
<ContentCopy class="w-4 h-4" />
{$t('social_share.copy_image')}
</button>
{#if isPublic}
<button class="btn btn-neutral gap-2" on:click={copyLink}>
{#if copiedLink}
<Check class="w-4 h-4 text-success" />
{$t('adventures.link_copied')}
{:else}
<LinkIcon class="w-4 h-4" />
{$t('adventures.copy_link')}
{/if}
</button>
{/if}
{#if canNativeShare}
<button class="btn btn-outline gap-2" on:click={nativeShare}>
<Share class="w-4 h-4" />
{$t('social_share.share_native')}
</button>
{/if}
</div>
</div>
</dialog>

View File

@@ -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 @@
<ShareModal {collection} on:close={() => (isShareModalOpen = false)} />
{/if}
{#if isSocialShareModalOpen}
<SocialShareModal
type="collection"
id={collection.id}
name={collection.name}
isPublic={!!collection.is_public}
on:close={() => (isSocialShareModalOpen = false)}
/>
{/if}
<div
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
>
@@ -360,6 +398,15 @@
{$t('adventures.share')}
</button>
</li>
<li>
<button
class="flex items-center gap-2"
on:click={() => (isSocialShareModalOpen = true)}
>
<ImageOutline class="w-4 h-4" />
{$t('social_share.share_externally')}
</button>
</li>
{#if collection.is_public}
<li>
<button on:click={copyLink} class="flex items-center gap-2">
@@ -394,6 +441,12 @@
</button>
</li>
{/if}
<li>
<button class="flex items-center gap-2" on:click={exportCollectionPdf}>
<FilePdfBox class="w-4 h-4" />
{$t('adventures.export_pdf')}
</button>
</li>
<li>
<button class="flex items-center gap-2" on:click={exportCollectionZip}>
<DownloadIcon class="w-4 h-4" />
@@ -451,6 +504,15 @@
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-64 p-2 shadow-xl border border-base-300"
>
<li>
<button
class="flex items-center gap-2"
on:click={() => (isSocialShareModalOpen = true)}
>
<ImageOutline class="w-4 h-4" />
{$t('social_share.share_externally')}
</button>
</li>
<li>
<button
class="text-error flex items-center gap-2"

View File

@@ -28,8 +28,10 @@
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
import CollectionItineraryPlanner from '../collections/CollectionItineraryPlanner.svelte';
import SocialShareModal from '../SocialShareModal.svelte';
import CalendarRemove from '~icons/mdi/calendar-remove';
import Globe from '~icons/mdi/globe';
import ImageOutline from '~icons/mdi/image-outline';
import { DEFAULT_CURRENCY, formatMoney, toMoneyValue } from '$lib/money';
export let type: string | null = null;
@@ -41,6 +43,7 @@
let isCollectionModalOpen: boolean = false;
let isWarningModalOpen: boolean = false;
let isSocialShareModalOpen: boolean = false;
let copied: boolean = false;
let isActionsMenuOpen: boolean = false;
let actionsMenuRef: HTMLDivElement | null = null;
@@ -274,6 +277,16 @@
/>
{/if}
{#if isSocialShareModalOpen}
<SocialShareModal
type="location"
id={adventure.id}
name={adventure.name}
isPublic={!!adventure.is_public}
on:close={() => (isSocialShareModalOpen = false)}
/>
{/if}
<div
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
aria-label="location-card"
@@ -490,6 +503,21 @@
</li>
{/if}
{#if adventure.user && adventure.user.uuid == user?.uuid}
<li>
<button
on:click={() => {
isActionsMenuOpen = false;
isSocialShareModalOpen = true;
}}
class="flex items-center gap-2"
>
<ImageOutline class="w-4 h-4" />
{$t('social_share.share_externally')}
</button>
</li>
{/if}
{#if itineraryItem && itineraryItem.id}
<div class="divider my-1"></div>
{#if !itineraryItem.is_global}

View File

@@ -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}`,
};
}

View File

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

View File

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

View File

@@ -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}
<SocialShareModal
type="collection"
id={collection.id}
name={collection.name}
isPublic={!!collection.is_public}
on:close={() => (isSocialShareModalOpen = false)}
/>
{/if}
{#if isLocationLinkModalOpen && collection}
<LocationLink
user={data.user}
@@ -1086,7 +1130,7 @@
<!-- Main Content -->
<div class="container mx-auto px-2 sm:px-4 py-6 sm:py-8 max-w-7xl">
<!-- View Switcher -->
<div class="flex justify-center mb-6">
<div class="flex flex-col sm:flex-row items-center justify-center gap-3 mb-6">
<div class="join">
{#if availableViews.all}
<button
@@ -1149,6 +1193,24 @@
</button>
{/if}
</div>
<div class="flex flex-wrap gap-2">
<button
class="btn btn-outline btn-primary gap-2"
on:click={() => (isSocialShareModalOpen = true)}
>
<ImageOutline class="w-5 h-5" aria-hidden="true" />
<span>{$t('social_share.share_externally')}</span>
</button>
<button
class="btn btn-outline btn-primary gap-2"
on:click={exportCollectionPdf}
disabled={isExportingPdf}
aria-busy={isExportingPdf}
>
<FilePdfBox class="w-5 h-5" aria-hidden="true" />
<span>{isExportingPdf ? '...' : $t('adventures.export_pdf')}</span>
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 sm:gap-10">
@@ -1609,5 +1671,18 @@
<title>
{collection && collection.name ? `${collection.name}` : 'Collection'}
</title>
<meta name="description" content="View collection details and locations" />
{#if data.shareMeta}
<meta name="description" content={data.shareMeta.description} />
<meta property="og:title" content={data.shareMeta.title} />
<meta property="og:description" content={data.shareMeta.description} />
<meta property="og:image" content={data.shareMeta.imageUrl} />
<meta property="og:url" content={data.shareMeta.pageUrl} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={data.shareMeta.title} />
<meta name="twitter:description" content={data.shareMeta.description} />
<meta name="twitter:image" content={data.shareMeta.imageUrl} />
{:else}
<meta name="description" content="View collection details and locations" />
{/if}
</svelte:head>

View File

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

View File

@@ -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}
<SocialShareModal
type="location"
id={adventure.id}
name={adventure.name}
isPublic={!!adventure.is_public}
on:close={() => (isSocialShareModalOpen = false)}
/>
{/if}
{#if !adventure && !notFound}
<div class="hero min-h-screen overflow-x-hidden">
<div class="hero-content">
@@ -244,6 +257,18 @@
{$t('adventures.edit_location')}
</button>
</li>
<li>
<button
on:click={() => {
isFabMenuOpen = false;
isSocialShareModalOpen = true;
}}
class="flex items-center gap-2"
>
<ImageOutline class="w-5 h-5" />
{$t('social_share.share_externally')}
</button>
</li>
<li>
<button
on:click={duplicateAdventure}
@@ -984,5 +1009,18 @@
? `${data.props.adventure.name}`
: 'Adventure'}
</title>
<meta name="description" content="Explore the world and add countries to your visited list!" />
{#if data.shareMeta}
<meta name="description" content={data.shareMeta.description} />
<meta property="og:title" content={data.shareMeta.title} />
<meta property="og:description" content={data.shareMeta.description} />
<meta property="og:image" content={data.shareMeta.imageUrl} />
<meta property="og:url" content={data.shareMeta.pageUrl} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={data.shareMeta.title} />
<meta name="twitter:description" content={data.shareMeta.description} />
<meta name="twitter:image" content={data.shareMeta.imageUrl} />
{:else}
<meta name="description" content="Explore the world and add countries to your visited list!" />
{/if}
</svelte:head>