mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-07-30 23:38:02 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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',)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
BIN
backend/server/adventures/pdf_assets/fonts/NotoEmoji-Regular.ttf
Normal file
BIN
backend/server/adventures/pdf_assets/fonts/NotoEmoji-Regular.ttf
Normal file
Binary file not shown.
BIN
backend/server/adventures/pdf_assets/fonts/NotoSans-Regular.ttf
Normal file
BIN
backend/server/adventures/pdf_assets/fonts/NotoSans-Regular.ttf
Normal file
Binary file not shown.
2
backend/server/adventures/pdf_assets/fonts/OFL.txt
Normal file
2
backend/server/adventures/pdf_assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Noto Sans and Noto Emoji are licensed under the SIL Open Font License 1.1.
|
||||
See https://openfontlicense.org/
|
||||
@@ -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()
|
||||
|
||||
|
||||
787
backend/server/adventures/services/collection_pdf.py
Normal file
787
backend/server/adventures/services/collection_pdf.py
Normal 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'
|
||||
147
backend/server/adventures/services/pdf_fonts.py
Normal file
147
backend/server/adventures/services/pdf_fonts.py
Normal 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('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
)
|
||||
|
||||
|
||||
def pdf_markup(text: str, *, emoji_font: str | None = None) -> str:
|
||||
"""
|
||||
Escape text for ReportLab Paragraph XML and wrap emoji runs in the emoji font.
|
||||
"""
|
||||
if not text:
|
||||
return ''
|
||||
_, emoji_registered = ensure_pdf_fonts()
|
||||
font = emoji_font or emoji_registered
|
||||
parts: list[str] = []
|
||||
for chunk, is_emoji in _split_text_runs(text):
|
||||
escaped = escape_xml(chunk)
|
||||
if is_emoji and font:
|
||||
parts.append(f'<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
|
||||
435
backend/server/adventures/services/share_image.py
Normal file
435
backend/server/adventures/services/share_image.py
Normal 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)
|
||||
98
backend/server/adventures/tests/test_collection_pdf.py
Normal file
98
backend/server/adventures/tests/test_collection_pdf.py
Normal 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'))
|
||||
109
backend/server/adventures/tests/test_geo_points.py
Normal file
109
backend/server/adventures/tests/test_geo_points.py
Normal 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)
|
||||
166
backend/server/adventures/tests/test_share_image.py
Normal file
166
backend/server/adventures/tests/test_share_image.py
Normal 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'])
|
||||
30
backend/server/adventures/utils/geo.py
Normal file
30
backend/server/adventures/utils/geo.py
Normal 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
|
||||
226
backend/server/adventures/utils/serializer_geo_fields.py
Normal file
226
backend/server/adventures/utils/serializer_geo_fields.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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")},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
91
backend/server/main/admin_utils.py
Normal file
91
backend/server/main/admin_utils.py
Normal 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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
260
frontend/src/lib/components/SocialShareModal.svelte
Normal file
260
frontend/src/lib/components/SocialShareModal.svelte
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
115
frontend/src/lib/shareMeta.server.ts
Normal file
115
frontend/src/lib/shareMeta.server.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user