mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-07-30 15:28:30 -04:00
Refactor sunrise/sunset functionality and introduce caching
- Renamed `ExternalSunTimesThrottle` to `ExternalSunriseSunsetThrottle` for clarity. - Added `SunriseSunsetAPI` view to handle sunrise and sunset data retrieval. - Implemented caching for sunrise/sunset data using `get_or_fetch_cached`. - Created new service functions for fetching sunrise/sunset data from the external API. - Updated related tests to ensure proper functionality and caching behavior. - Removed deprecated sun times handling from the `LocationViewSet` for cleaner code.
This commit is contained in:
@@ -2,8 +2,13 @@ from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from adventures.providers.base import ProviderResult
|
||||
from adventures.services.external_cache import get_or_fetch_cached
|
||||
|
||||
WIKIPEDIA_SUMMARY_CACHE_PREFIX = 'wikipedia_summary_v1'
|
||||
WIKIPEDIA_SUMMARY_CACHE_TIMEOUT = getattr(settings, 'WIKIPEDIA_SUMMARY_CACHE_TIMEOUT', 60 * 60 * 24 * 7)
|
||||
|
||||
|
||||
def fetch_summary(query: str, language: str = "en") -> ProviderResult[Optional[str]]:
|
||||
@@ -11,9 +16,27 @@ def fetch_summary(query: str, language: str = "en") -> ProviderResult[Optional[s
|
||||
if not normalized_query:
|
||||
return ProviderResult(error="Missing query")
|
||||
|
||||
candidates = [normalized_query]
|
||||
if "," in normalized_query:
|
||||
head = normalized_query.split(",")[0].strip()
|
||||
normalized_language = (language or "en").strip().lower() or "en"
|
||||
|
||||
def fetch() -> Optional[str]:
|
||||
return _fetch_summary_uncached(normalized_query, normalized_language)
|
||||
|
||||
cached = get_or_fetch_cached(
|
||||
WIKIPEDIA_SUMMARY_CACHE_PREFIX,
|
||||
normalized_language,
|
||||
normalized_query,
|
||||
fetch_fn=fetch,
|
||||
timeout=WIKIPEDIA_SUMMARY_CACHE_TIMEOUT,
|
||||
)
|
||||
if cached:
|
||||
return ProviderResult(data=cached)
|
||||
return ProviderResult(data=None)
|
||||
|
||||
|
||||
def _fetch_summary_uncached(query: str, language: str) -> Optional[str]:
|
||||
candidates = [query]
|
||||
if "," in query:
|
||||
head = query.split(",")[0].strip()
|
||||
if head and head not in candidates:
|
||||
candidates.append(head)
|
||||
|
||||
@@ -35,8 +58,8 @@ def fetch_summary(query: str, language: str = "en") -> ProviderResult[Optional[s
|
||||
|
||||
extract = (data.get("extract") or "").strip()
|
||||
if len(extract) >= 120:
|
||||
return ProviderResult(data=extract)
|
||||
return extract
|
||||
except requests.exceptions.RequestException:
|
||||
continue
|
||||
|
||||
return ProviderResult(data=None)
|
||||
return None
|
||||
|
||||
0
backend/server/adventures/providers/sun/__init__.py
Normal file
0
backend/server/adventures/providers/sun/__init__.py
Normal file
48
backend/server/adventures/providers/sun/sunrisesunset.py
Normal file
48
backend/server/adventures/providers/sun/sunrisesunset.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
from typing import TypedDict
|
||||
|
||||
import requests
|
||||
|
||||
from adventures.providers.base import ProviderResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE_URL = 'https://api.sunrisesunset.io/json'
|
||||
|
||||
|
||||
class SunriseSunsetData(TypedDict):
|
||||
date: str
|
||||
sunrise: str
|
||||
sunset: str
|
||||
|
||||
|
||||
def fetch_sunrise_sunset(latitude: float, longitude: float, date: str) -> ProviderResult[SunriseSunsetData]:
|
||||
"""Fetch sunrise and sunset for a coordinate and calendar date (YYYY-MM-DD)."""
|
||||
if latitude is None or longitude is None:
|
||||
return ProviderResult(error='Missing coordinates')
|
||||
if not date:
|
||||
return ProviderResult(error='Missing date')
|
||||
|
||||
api_url = f'{API_BASE_URL}?lat={latitude}&lng={longitude}&date={date}'
|
||||
try:
|
||||
response = requests.get(api_url, timeout=10)
|
||||
except requests.RequestException as exc:
|
||||
logger.warning('Sunrise/sunset API request failed for %s: %s', date, exc)
|
||||
return ProviderResult(error='Sunrise/sunset API request failed')
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning('Sunrise/sunset API returned status %s for %s', response.status_code, date)
|
||||
return ProviderResult(error='Sunrise/sunset API returned an error')
|
||||
|
||||
data = response.json() or {}
|
||||
results = data.get('results', {})
|
||||
sunrise = results.get('sunrise')
|
||||
sunset = results.get('sunset')
|
||||
if not sunrise or not sunset:
|
||||
return ProviderResult(error='Sunrise/sunset not available for this date')
|
||||
|
||||
return ProviderResult(data={
|
||||
'date': date,
|
||||
'sunrise': sunrise,
|
||||
'sunset': sunset,
|
||||
})
|
||||
44
backend/server/adventures/services/external_cache.py
Normal file
44
backend/server/adventures/services/external_cache.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import hashlib
|
||||
from typing import Callable, Optional, TypeVar
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def build_cache_key(prefix: str, *parts: str) -> str:
|
||||
normalized = ':'.join((part or '').strip().lower() for part in parts)
|
||||
if len(normalized) > 200:
|
||||
digest = hashlib.sha256(normalized.encode()).hexdigest()
|
||||
return f'{prefix}:{digest}'
|
||||
return f'{prefix}:{normalized}'
|
||||
|
||||
|
||||
def get_cached(prefix: str, *parts: str) -> Optional[T]:
|
||||
return cache.get(build_cache_key(prefix, *parts))
|
||||
|
||||
|
||||
def set_cached(prefix: str, *parts: str, value: T, timeout: Optional[int] = None) -> T:
|
||||
cache.set(
|
||||
build_cache_key(prefix, *parts),
|
||||
value,
|
||||
timeout or getattr(settings, 'EXTERNAL_API_CACHE_TIMEOUT', 60 * 60 * 24),
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def get_or_fetch_cached(
|
||||
prefix: str,
|
||||
*parts: str,
|
||||
fetch_fn: Callable[[], Optional[T]],
|
||||
timeout: Optional[int] = None,
|
||||
) -> Optional[T]:
|
||||
cached = get_cached(prefix, *parts)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
value = fetch_fn()
|
||||
if value is not None:
|
||||
set_cached(prefix, *parts, value=value, timeout=timeout)
|
||||
return value
|
||||
@@ -1,39 +0,0 @@
|
||||
import logging
|
||||
from typing import List, Dict
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_sun_times_for_visits(latitude: float, longitude: float, visits: List[Dict]) -> List[Dict]:
|
||||
"""Return sunrise/sunset times for visits given lat/lon and visit date entries.
|
||||
|
||||
visits: list of dicts with keys 'start_date' and 'id'
|
||||
"""
|
||||
sun_times = []
|
||||
for visit in visits:
|
||||
date = visit.get('start_date')
|
||||
if not (date and latitude and longitude):
|
||||
continue
|
||||
|
||||
api_url = (
|
||||
f'https://api.sunrisesunset.io/json?'
|
||||
f'lat={latitude}&lng={longitude}&date={date}'
|
||||
)
|
||||
try:
|
||||
response = requests.get(api_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
results = data.get('results', {})
|
||||
if results.get('sunrise') and results.get('sunset'):
|
||||
sun_times.append({
|
||||
'date': date,
|
||||
'visit_id': visit.get('id'),
|
||||
'sunrise': results.get('sunrise'),
|
||||
'sunset': results.get('sunset'),
|
||||
})
|
||||
except requests.RequestException:
|
||||
logger.debug('Sun times API request failed for date %s', date)
|
||||
continue
|
||||
|
||||
return sun_times
|
||||
71
backend/server/adventures/services/sunrise_sunset/times.py
Normal file
71
backend/server/adventures/services/sunrise_sunset/times.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import logging
|
||||
from datetime import timezone as dt_timezone
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.dateparse import parse_date, parse_datetime
|
||||
|
||||
from adventures.providers.sun.sunrisesunset import SunriseSunsetData, fetch_sunrise_sunset
|
||||
from adventures.services.external_cache import get_or_fetch_cached
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUNRISE_SUNSET_CACHE_PREFIX = 'sunrise_sunset_v1'
|
||||
SUNRISE_SUNSET_CACHE_TIMEOUT = getattr(settings, 'SUNRISE_SUNSET_CACHE_TIMEOUT', 60 * 60 * 24 * 30)
|
||||
|
||||
|
||||
def normalize_sunrise_sunset_date(value: str) -> Optional[str]:
|
||||
"""Normalize an ISO datetime or YYYY-MM-DD string to YYYY-MM-DD."""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
value = value.strip()
|
||||
if len(value) >= 10 and value[4:5] == '-' and value[7:8] == '-':
|
||||
parsed_date = parse_date(value[:10])
|
||||
if parsed_date:
|
||||
return parsed_date.isoformat()
|
||||
|
||||
parsed_datetime = parse_datetime(value)
|
||||
if parsed_datetime:
|
||||
if parsed_datetime.tzinfo is not None:
|
||||
parsed_datetime = parsed_datetime.astimezone(dt_timezone.utc)
|
||||
return parsed_datetime.date().isoformat()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _cache_key_parts(latitude: float, longitude: float, date_value: str) -> tuple[str, str, str]:
|
||||
return (
|
||||
f'{latitude:.4f}',
|
||||
f'{longitude:.4f}',
|
||||
date_value,
|
||||
)
|
||||
|
||||
|
||||
def get_sunrise_sunset_for_date(
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
date_value: str,
|
||||
) -> Optional[SunriseSunsetData]:
|
||||
"""Return cached sunrise/sunset for a coordinate and calendar date."""
|
||||
normalized_date = normalize_sunrise_sunset_date(date_value)
|
||||
if not normalized_date:
|
||||
return None
|
||||
|
||||
lat_part, lng_part, _ = _cache_key_parts(latitude, longitude, normalized_date)
|
||||
|
||||
def fetch() -> Optional[SunriseSunsetData]:
|
||||
result = fetch_sunrise_sunset(latitude, longitude, normalized_date)
|
||||
if result.error or not result.data:
|
||||
logger.debug('Sunrise/sunset unavailable for %s: %s', normalized_date, result.error)
|
||||
return None
|
||||
return result.data
|
||||
|
||||
return get_or_fetch_cached(
|
||||
SUNRISE_SUNSET_CACHE_PREFIX,
|
||||
lat_part,
|
||||
lng_part,
|
||||
normalized_date,
|
||||
fetch_fn=fetch,
|
||||
timeout=SUNRISE_SUNSET_CACHE_TIMEOUT,
|
||||
)
|
||||
@@ -3,11 +3,16 @@ import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
from django.conf import settings
|
||||
|
||||
from adventures.services.external_cache import get_or_fetch_cached
|
||||
from adventures.services.wikipedia.search import WikipediaClient, is_valid_image_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WIKIPEDIA_DESC_CACHE_PREFIX = 'wikipedia_desc_v1'
|
||||
WIKIPEDIA_DESC_CACHE_TIMEOUT = getattr(settings, 'WIKIPEDIA_DESC_CACHE_TIMEOUT', 60 * 60 * 24 * 7)
|
||||
|
||||
|
||||
class WikipediaDescriptionService:
|
||||
LANGUAGE_PATTERN = re.compile(r"^[a-z0-9-]{2,12}$", re.IGNORECASE)
|
||||
@@ -47,6 +52,22 @@ class WikipediaDescriptionService:
|
||||
def get_description(self, term: str, lang: str) -> Optional[Dict]:
|
||||
if not term:
|
||||
return None
|
||||
|
||||
normalized_term = term.strip()
|
||||
normalized_lang = self.get_language(lang)
|
||||
|
||||
def fetch() -> Optional[Dict]:
|
||||
return self._fetch_description(normalized_term, normalized_lang)
|
||||
|
||||
return get_or_fetch_cached(
|
||||
WIKIPEDIA_DESC_CACHE_PREFIX,
|
||||
normalized_lang,
|
||||
normalized_term,
|
||||
fetch_fn=fetch,
|
||||
timeout=WIKIPEDIA_DESC_CACHE_TIMEOUT,
|
||||
)
|
||||
|
||||
def _fetch_description(self, term: str, lang: str) -> Optional[Dict]:
|
||||
candidates = self.client.get_candidate_pages(term, lang, max_candidates=self.MAX_CANDIDATES)
|
||||
for candidate in candidates:
|
||||
try:
|
||||
|
||||
41
backend/server/adventures/tests/test_sunrise_sunset.py
Normal file
41
backend/server/adventures/tests/test_sunrise_sunset.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
|
||||
from adventures.services.sunrise_sunset.times import (
|
||||
get_sunrise_sunset_for_date,
|
||||
normalize_sunrise_sunset_date,
|
||||
)
|
||||
|
||||
|
||||
class NormalizeSunriseSunsetDateTests(SimpleTestCase):
|
||||
def test_accepts_iso_date(self):
|
||||
self.assertEqual(normalize_sunrise_sunset_date('2024-06-15'), '2024-06-15')
|
||||
|
||||
def test_accepts_iso_datetime(self):
|
||||
self.assertEqual(normalize_sunrise_sunset_date('2024-06-15T14:30:00Z'), '2024-06-15')
|
||||
|
||||
def test_rejects_invalid_value(self):
|
||||
self.assertIsNone(normalize_sunrise_sunset_date('not-a-date'))
|
||||
|
||||
|
||||
@override_settings(SUNRISE_SUNSET_CACHE_TIMEOUT=3600)
|
||||
class GetSunriseSunsetForDateTests(SimpleTestCase):
|
||||
@patch('adventures.services.sunrise_sunset.times.get_or_fetch_cached')
|
||||
def test_delegates_to_cache_helper(self, mock_get_or_fetch_cached):
|
||||
mock_get_or_fetch_cached.return_value = {
|
||||
'date': '2024-06-15',
|
||||
'sunrise': '5:30 AM',
|
||||
'sunset': '8:15 PM',
|
||||
}
|
||||
|
||||
result = get_sunrise_sunset_for_date(40.7128, -74.0060, '2024-06-15T00:00:00Z')
|
||||
|
||||
self.assertEqual(result['sunrise'], '5:30 AM')
|
||||
mock_get_or_fetch_cached.assert_called_once()
|
||||
args = mock_get_or_fetch_cached.call_args.args
|
||||
self.assertEqual(args[0], 'sunrise_sunset_v1')
|
||||
self.assertEqual(args[3], '2024-06-15')
|
||||
|
||||
def test_returns_none_for_invalid_date(self):
|
||||
self.assertIsNone(get_sunrise_sunset_for_date(40.7128, -74.0060, 'invalid'))
|
||||
22
backend/server/adventures/tests/test_wikipedia_cache.py
Normal file
22
backend/server/adventures/tests/test_wikipedia_cache.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
|
||||
from adventures.services.wikipedia.description_service import WikipediaDescriptionService
|
||||
|
||||
|
||||
@override_settings(WIKIPEDIA_DESC_CACHE_TIMEOUT=3600)
|
||||
class WikipediaDescriptionCacheTests(SimpleTestCase):
|
||||
@patch('adventures.services.wikipedia.description_service.get_or_fetch_cached')
|
||||
def test_get_description_uses_cache(self, mock_get_or_fetch_cached):
|
||||
mock_get_or_fetch_cached.return_value = {'extract': 'A sample description', 'title': 'Paris'}
|
||||
|
||||
service = WikipediaDescriptionService()
|
||||
result = service.get_description('Paris', 'en')
|
||||
|
||||
self.assertEqual(result['extract'], 'A sample description')
|
||||
mock_get_or_fetch_cached.assert_called_once()
|
||||
args = mock_get_or_fetch_cached.call_args.args
|
||||
self.assertEqual(args[0], 'wikipedia_desc_v1')
|
||||
self.assertEqual(args[1], 'en')
|
||||
self.assertEqual(args[2], 'Paris')
|
||||
@@ -29,5 +29,5 @@ class ExternalWikipediaThrottle(ConditionalUserRateThrottle):
|
||||
scope = "external_wikipedia"
|
||||
|
||||
|
||||
class ExternalSunTimesThrottle(ConditionalUserRateThrottle):
|
||||
scope = "external_sun_times"
|
||||
class ExternalSunriseSunsetThrottle(ConditionalUserRateThrottle):
|
||||
scope = "external_sunrise_sunset"
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from adventures.views import *
|
||||
from adventures.views.places_api_view import PlacesAPI
|
||||
from adventures.views.sunrise_sunset_view import SunriseSunsetAPI
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'locations', LocationViewSet, basename='locations')
|
||||
@@ -15,6 +16,7 @@ router.register(r'checklists', ChecklistViewSet, basename='checklists')
|
||||
router.register(r'images', ContentImageViewSet, basename='images')
|
||||
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
|
||||
router.register(r'places', PlacesAPI, basename='places')
|
||||
router.register(r'sunrise-sunset', SunriseSunsetAPI, basename='sunrise-sunset')
|
||||
router.register(r'categories', CategoryViewSet, basename='categories')
|
||||
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
|
||||
router.register(r'search', GlobalSearchView, basename='search')
|
||||
|
||||
18
backend/server/adventures/utils/location_access.py
Normal file
18
backend/server/adventures/utils/location_access.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from adventures.models import Location
|
||||
|
||||
|
||||
def user_can_access_location(location: Location, user) -> bool:
|
||||
if location.is_public:
|
||||
return True
|
||||
|
||||
if user.is_authenticated and location.user == user:
|
||||
return True
|
||||
|
||||
if user.is_authenticated:
|
||||
for collection in location.collections.all():
|
||||
if collection.user == user:
|
||||
return True
|
||||
if collection.shared_with.filter(uuid=user.uuid).exists():
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -20,8 +20,7 @@ from adventures.serializers import (
|
||||
MapPinSerializer,
|
||||
)
|
||||
from adventures.utils import pagination
|
||||
from adventures.throttling import ExternalGeocodeThrottle, ExternalSunTimesThrottle
|
||||
from adventures.utils.geo import point_to_lat_lon
|
||||
from adventures.throttling import ExternalGeocodeThrottle
|
||||
from adventures.services.geocoding.reverse import reverse_geocode as reverse_geocode_service
|
||||
from adventures.services.share_image import (
|
||||
build_share_image,
|
||||
@@ -393,32 +392,20 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||
serializer = CalendarLocationSerializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=['get'],
|
||||
url_path='additional-info',
|
||||
throttle_classes=[ExternalSunTimesThrottle],
|
||||
)
|
||||
@action(detail=True, methods=['get'], url_path='additional-info')
|
||||
def additional_info(self, request, pk=None):
|
||||
"""Get adventure with additional sunrise/sunset information."""
|
||||
"""Get adventure with extended details (kept for backwards compatibility)."""
|
||||
adventure = self.get_object()
|
||||
user = request.user
|
||||
|
||||
# Validate access permissions
|
||||
if not self._has_adventure_access(adventure, user):
|
||||
return Response(
|
||||
{"error": "User does not have permission to access this adventure"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Get base adventure data
|
||||
serializer = self.get_serializer(adventure)
|
||||
response_data = serializer.data
|
||||
|
||||
# Add sunrise/sunset data
|
||||
response_data['sun_times'] = self._get_sun_times(adventure, response_data.get('visits', []))
|
||||
|
||||
return Response(response_data)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def duplicate(self, request, pk=None):
|
||||
@@ -812,16 +799,6 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return False
|
||||
|
||||
def _get_sun_times(self, adventure, visits):
|
||||
"""Get sunrise/sunset times for adventure visits via service."""
|
||||
from adventures.services.sun.times import get_sun_times_for_visits
|
||||
|
||||
try:
|
||||
latitude, longitude = point_to_lat_lon(adventure.coordinates)
|
||||
return get_sun_times_for_visits(latitude, longitude, visits)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def paginate_and_respond(self, queryset, request):
|
||||
"""Paginate queryset and return response."""
|
||||
paginator = self.pagination_class()
|
||||
|
||||
49
backend/server/adventures/views/sunrise_sunset_view.py
Normal file
49
backend/server/adventures/views/sunrise_sunset_view.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
from adventures.models import Location
|
||||
from adventures.services.sunrise_sunset.times import get_sunrise_sunset_for_date
|
||||
from adventures.throttling import ExternalSunriseSunsetThrottle
|
||||
from adventures.utils.geo import point_to_lat_lon
|
||||
from adventures.utils.location_access import user_can_access_location
|
||||
|
||||
|
||||
class SunriseSunsetAPI(viewsets.ViewSet):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@action(detail=False, methods=['get'], throttle_classes=[ExternalSunriseSunsetThrottle])
|
||||
def lookup(self, request):
|
||||
location_id = request.query_params.get('location_id', '').strip()
|
||||
date_value = request.query_params.get('date', '').strip()
|
||||
|
||||
if not location_id:
|
||||
return Response({'error': 'location_id parameter is required'}, status=400)
|
||||
if not date_value:
|
||||
return Response({'error': 'date parameter is required'}, status=400)
|
||||
|
||||
try:
|
||||
location = Location.objects.get(pk=location_id)
|
||||
except Location.DoesNotExist:
|
||||
return Response({'error': 'Location not found'}, status=404)
|
||||
|
||||
if not user_can_access_location(location, request.user):
|
||||
return Response(
|
||||
{'error': 'User does not have permission to access this location'},
|
||||
status=403,
|
||||
)
|
||||
|
||||
if not location.coordinates:
|
||||
return Response({'error': 'Location has no coordinates'}, status=400)
|
||||
|
||||
try:
|
||||
latitude, longitude = point_to_lat_lon(location.coordinates)
|
||||
except Exception:
|
||||
return Response({'error': 'Location coordinates are invalid'}, status=400)
|
||||
|
||||
sunrise_sunset = get_sunrise_sunset_for_date(latitude, longitude, date_value)
|
||||
if not sunrise_sunset:
|
||||
return Response({'error': 'Sunrise/sunset not available for this date'}, status=404)
|
||||
|
||||
return Response(sunrise_sunset)
|
||||
@@ -333,9 +333,14 @@ RATE_LIMIT_RATES = {
|
||||
'external_geocode': getenv('RATE_LIMIT_EXTERNAL_GEOCODE', '120/minute'),
|
||||
'external_recommendations': getenv('RATE_LIMIT_EXTERNAL_RECOMMENDATIONS', '30/minute'),
|
||||
'external_wikipedia': getenv('RATE_LIMIT_EXTERNAL_WIKIPEDIA', '60/minute'),
|
||||
'external_sun_times': getenv('RATE_LIMIT_EXTERNAL_SUN_TIMES', '30/minute'),
|
||||
'external_sunrise_sunset': getenv('RATE_LIMIT_EXTERNAL_SUNRISE_SUNSET', '30/minute'),
|
||||
}
|
||||
|
||||
SUNRISE_SUNSET_CACHE_TIMEOUT = int(getenv('SUNRISE_SUNSET_CACHE_TIMEOUT', str(60 * 60 * 24 * 30)))
|
||||
WIKIPEDIA_DESC_CACHE_TIMEOUT = int(getenv('WIKIPEDIA_DESC_CACHE_TIMEOUT', str(60 * 60 * 24 * 7)))
|
||||
WIKIPEDIA_SUMMARY_CACHE_TIMEOUT = int(getenv('WIKIPEDIA_SUMMARY_CACHE_TIMEOUT', str(60 * 60 * 24 * 7)))
|
||||
EXTERNAL_API_CACHE_TIMEOUT = int(getenv('EXTERNAL_API_CACHE_TIMEOUT', str(60 * 60 * 24)))
|
||||
|
||||
FORCE_SOCIALACCOUNT_LOGIN = getenv('FORCE_SOCIALACCOUNT_LOGIN', 'false').lower() == 'true' # When true, only social login is allowed (no password login) and the login page will show only social providers or redirect directly to the first provider if only one is configured.
|
||||
|
||||
if getenv('EMAIL_BACKEND', 'console') == 'console':
|
||||
|
||||
@@ -30,7 +30,7 @@ Per-endpoint overrides (all optional):
|
||||
| `RATE_LIMIT_EXTERNAL_GEOCODE` | `120/minute` | External geocoding |
|
||||
| `RATE_LIMIT_EXTERNAL_RECOMMENDATIONS` | `30/minute` | Recommendations |
|
||||
| `RATE_LIMIT_EXTERNAL_WIKIPEDIA` | `60/minute` | Wikipedia lookups |
|
||||
| `RATE_LIMIT_EXTERNAL_SUN_TIMES` | `30/minute` | Sun times API |
|
||||
| `RATE_LIMIT_EXTERNAL_SUNRISE_SUNSET` | `30/minute` | Sunrise/sunset API |
|
||||
|
||||
## Performance
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ Related guides: [Social Auth](social_auth.md), [Disable Registration](disable_re
|
||||
| `RATE_LIMIT_EXTERNAL_GEOCODE` | No | External geocoding limit. | `120/minute` |
|
||||
| `RATE_LIMIT_EXTERNAL_RECOMMENDATIONS` | No | Recommendations API limit. | `30/minute` |
|
||||
| `RATE_LIMIT_EXTERNAL_WIKIPEDIA` | No | Wikipedia lookup limit. | `60/minute` |
|
||||
| `RATE_LIMIT_EXTERNAL_SUN_TIMES` | No | Sun times API limit. | `30/minute` |
|
||||
| `RATE_LIMIT_EXTERNAL_SUNRISE_SUNSET` | No | Sunrise/sunset API limit. | `30/minute` |
|
||||
|
||||
See [Advanced Configuration](advanced_configuration.md) for usage notes.
|
||||
|
||||
|
||||
27
frontend/src/lib/sunriseSunset.ts
Normal file
27
frontend/src/lib/sunriseSunset.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type SunriseSunset = {
|
||||
date: string;
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
};
|
||||
|
||||
export function visitDateKey(startDate: string | null | undefined): string | null {
|
||||
if (!startDate) return null;
|
||||
return startDate.split('T')[0] ?? null;
|
||||
}
|
||||
|
||||
export async function fetchSunriseSunset(
|
||||
locationId: string,
|
||||
date: string
|
||||
): Promise<SunriseSunset | null> {
|
||||
const params = new URLSearchParams({
|
||||
location_id: locationId,
|
||||
date
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/sunrise-sunset/lookup/?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as SunriseSunset;
|
||||
}
|
||||
@@ -100,15 +100,6 @@ export type Location = {
|
||||
trails: Trail[];
|
||||
};
|
||||
|
||||
export type AdditionalLocation = Location & {
|
||||
sun_times: {
|
||||
date: string;
|
||||
visit_id: string;
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type Country = {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -154,9 +154,11 @@
|
||||
"distance": "Distance",
|
||||
"share_collection": "Share this Collection!",
|
||||
"copy_link": "Copy Link",
|
||||
"sun_times": "Sun Times",
|
||||
"sunrise": "Sunrise",
|
||||
"sunset": "Sunset",
|
||||
"show_sunrise_sunset": "Show sunrise & sunset",
|
||||
"loading_sunrise_sunset": "Loading sunrise & sunset...",
|
||||
"sunrise_sunset_load_error": "Could not load sunrise and sunset times",
|
||||
"open_in_maps": "Open in Maps",
|
||||
"fetch_image": "Fetch Image",
|
||||
"wikipedia": "Wikipedia",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
import type { AdditionalLocation, Location, Collection } from '$lib/types';
|
||||
import type { Location } from '$lib/types';
|
||||
import { buildLocationShareMeta } from '$lib/shareMeta.server';
|
||||
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
export const load = (async (event) => {
|
||||
const id = event.params as { id: string };
|
||||
let request = await fetch(`${endpoint}/api/locations/${id.id}/additional-info/`, {
|
||||
let request = await fetch(`${endpoint}/api/locations/${id.id}/`, {
|
||||
headers: {
|
||||
Cookie: `sessionid=${event.cookies.get('sessionid')}`
|
||||
},
|
||||
@@ -21,7 +21,7 @@ export const load = (async (event) => {
|
||||
shareMeta: null
|
||||
};
|
||||
} else {
|
||||
let adventure = (await request.json()) as AdditionalLocation;
|
||||
let adventure = (await request.json()) as Location;
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { AdditionalLocation } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Location } from '$lib/types';
|
||||
import { fetchSunriseSunset, visitDateKey, type SunriseSunset } from '$lib/sunriseSunset';
|
||||
import type { PageData } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
import Lost from '$lib/assets/undraw_lost.svg';
|
||||
@@ -42,9 +42,10 @@
|
||||
|
||||
export let data: PageData;
|
||||
let measurementSystem = data.user?.measurement_system || 'metric';
|
||||
console.log(data);
|
||||
|
||||
let adventure: AdditionalLocation;
|
||||
let adventure: Location | undefined;
|
||||
let visitSunriseSunset: Record<string, SunriseSunset> = {};
|
||||
let sunriseSunsetLoading: Record<string, boolean> = {};
|
||||
let currentSlide = 0;
|
||||
|
||||
$: adventurePriceLabel = adventure
|
||||
@@ -64,7 +65,7 @@
|
||||
let notFound: boolean = false;
|
||||
let isEditModalOpen: boolean = false;
|
||||
let isSocialShareModalOpen: boolean = false;
|
||||
let adventure_images: { image: string; adventure: AdditionalLocation | null }[] = [];
|
||||
let adventure_images: { image: string; adventure: Location | null }[] = [];
|
||||
let modalInitialIndex: number = 0;
|
||||
let isImageModalOpen: boolean = false;
|
||||
let mapBasemapType = normalizeBasemapType(data.user?.map_style);
|
||||
@@ -81,52 +82,86 @@
|
||||
: { type: 'FeatureCollection', features: [] };
|
||||
$: hasImagePins = imagePinGeoJson.features.length > 0;
|
||||
|
||||
onMount(async () => {
|
||||
if (data.props.adventure) {
|
||||
adventure = data.props.adventure;
|
||||
adventure.images.sort((a, b) => {
|
||||
if (a.is_primary && !b.is_primary) {
|
||||
return -1;
|
||||
} else if (!a.is_primary && b.is_primary) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Sort visits by their start date (oldest first / chronological). Fall back to created_at if start_date is missing.
|
||||
if (adventure.visits && adventure.visits.length > 1) {
|
||||
adventure.visits.sort((a, b) => {
|
||||
const aTs = DateTime.fromISO(a.start_date || a.created_at || '').toMillis() || 0;
|
||||
const bTs = DateTime.fromISO(b.start_date || b.created_at || '').toMillis() || 0;
|
||||
return aTs - bTs; // oldest first (chronological)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
notFound = true;
|
||||
async function loadSunriseSunsetForDate(date: string) {
|
||||
if (!adventure?.id || sunriseSunsetLoading[date] || visitSunriseSunset[date]) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
function hasActivityGeojson(adventure: AdditionalLocation) {
|
||||
sunriseSunsetLoading = { ...sunriseSunsetLoading, [date]: true };
|
||||
|
||||
try {
|
||||
const sunriseSunset = await fetchSunriseSunset(adventure.id, date);
|
||||
if (sunriseSunset) {
|
||||
visitSunriseSunset = { ...visitSunriseSunset, [date]: sunriseSunset };
|
||||
} else {
|
||||
addToast('error', $t('adventures.sunrise_sunset_load_error'));
|
||||
}
|
||||
} finally {
|
||||
const { [date]: _removed, ...rest } = sunriseSunsetLoading;
|
||||
sunriseSunsetLoading = rest;
|
||||
}
|
||||
}
|
||||
|
||||
function applyLocationPageData(adventureData: Location | null | undefined) {
|
||||
if (!adventureData) {
|
||||
notFound = true;
|
||||
adventure = undefined;
|
||||
visitSunriseSunset = {};
|
||||
sunriseSunsetLoading = {};
|
||||
currentSlide = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
notFound = false;
|
||||
adventure = adventureData;
|
||||
adventure.images.sort((a, b) => {
|
||||
if (a.is_primary && !b.is_primary) {
|
||||
return -1;
|
||||
} else if (!a.is_primary && b.is_primary) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (adventure.visits && adventure.visits.length > 1) {
|
||||
adventure.visits.sort((a, b) => {
|
||||
const aTs = DateTime.fromISO(a.start_date || a.created_at || '').toMillis() || 0;
|
||||
const bTs = DateTime.fromISO(b.start_date || b.created_at || '').toMillis() || 0;
|
||||
return aTs - bTs;
|
||||
});
|
||||
}
|
||||
|
||||
visitSunriseSunset = {};
|
||||
sunriseSunsetLoading = {};
|
||||
currentSlide = 0;
|
||||
isImageModalOpen = false;
|
||||
isEditModalOpen = false;
|
||||
isSocialShareModalOpen = false;
|
||||
}
|
||||
|
||||
$: applyLocationPageData(data.props.adventure);
|
||||
|
||||
function hasActivityGeojson(adventure: Location) {
|
||||
return adventure.visits.some((visit) => visit.activities.some((activity) => activity.geojson));
|
||||
}
|
||||
|
||||
function hasAttachmentGeojson(adventure: AdditionalLocation) {
|
||||
function hasAttachmentGeojson(adventure: Location) {
|
||||
return adventure.attachments.some((attachment) => attachment.geojson);
|
||||
}
|
||||
|
||||
function hasTrailGeojson(adventure: AdditionalLocation) {
|
||||
function hasTrailGeojson(adventure: Location) {
|
||||
return adventure.trails?.some((trail) => trail.geojson) ?? false;
|
||||
}
|
||||
|
||||
function getTotalActivities(adventure: AdditionalLocation) {
|
||||
function getTotalActivities(adventure: Location) {
|
||||
return adventure.visits.reduce(
|
||||
(total, visit) => total + (visit.activities ? visit.activities.length : 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function getTotalDistance(adventure: AdditionalLocation) {
|
||||
function getTotalDistance(adventure: Location) {
|
||||
const totalMeters = adventure.visits.reduce(
|
||||
(total, visit) =>
|
||||
total +
|
||||
@@ -141,7 +176,7 @@
|
||||
return measurementSystem === 'imperial' ? totalKm * 0.621371 : totalKm;
|
||||
}
|
||||
|
||||
function getTotalElevationGain(adventure: AdditionalLocation) {
|
||||
function getTotalElevationGain(adventure: Location) {
|
||||
const totalMeters = adventure.visits.reduce(
|
||||
(total, visit) =>
|
||||
total +
|
||||
@@ -549,6 +584,7 @@
|
||||
<h2 class="card-title text-2xl mb-6">🎯 {$t('adventures.visits')}</h2>
|
||||
<div class="space-y-4">
|
||||
{#each adventure.visits as visit, index}
|
||||
{@const visitDate = visitDateKey(visit.start_date)}
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-4 h-4 bg-primary rounded-full"></div>
|
||||
@@ -559,71 +595,129 @@
|
||||
<div class="flex-1 pb-4">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body p-4">
|
||||
{#if isVisitAllDay(visit.start_date, visit.end_date)}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="badge badge-primary">All Day</span>
|
||||
<span class="font-semibold">
|
||||
{visit.start_date ? visit.start_date.split('T')[0] : ''} – {visit.end_date
|
||||
? visit.end_date.split('T')[0]
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-primary">🕓 {$t('adventures.timed')}</span>
|
||||
{#if visit.timezone}
|
||||
<span class="badge badge-outline">{visit.timezone}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{#if visit.timezone}
|
||||
<strong>{$t('adventures.start')}:</strong>
|
||||
{DateTime.fromISO(visit.start_date, { zone: 'utc' })
|
||||
.setZone(visit.timezone)
|
||||
.toLocaleString(DateTime.DATETIME_MED)}<br />
|
||||
<strong>{$t('adventures.end')}:</strong>
|
||||
{DateTime.fromISO(visit.end_date, { zone: 'utc' })
|
||||
.setZone(visit.timezone)
|
||||
.toLocaleString(DateTime.DATETIME_MED)}
|
||||
{:else}
|
||||
<strong>Start:</strong>
|
||||
{DateTime.fromISO(visit.start_date).toLocaleString(
|
||||
DateTime.DATETIME_MED
|
||||
)}<br />
|
||||
<strong>End:</strong>
|
||||
{DateTime.fromISO(visit.end_date).toLocaleString(
|
||||
DateTime.DATETIME_MED
|
||||
)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if visit.notes}
|
||||
<div class="mt-3 p-3 bg-base-200 rounded-lg">
|
||||
<p class="text-sm italic">"{visit.notes}"</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if isVisitAllDay(visit.start_date, visit.end_date)}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="badge badge-primary">All Day</span>
|
||||
<span class="font-semibold">
|
||||
{visit.start_date ? visit.start_date.split('T')[0] : ''} – {visit.end_date
|
||||
? visit.end_date.split('T')[0]
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-primary"
|
||||
>🕓 {$t('adventures.timed')}</span
|
||||
>
|
||||
{#if visit.timezone}
|
||||
<span class="badge badge-outline">{visit.timezone}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{#if visit.timezone}
|
||||
<strong>{$t('adventures.start')}:</strong>
|
||||
{DateTime.fromISO(visit.start_date, { zone: 'utc' })
|
||||
.setZone(visit.timezone)
|
||||
.toLocaleString(DateTime.DATETIME_MED)}<br />
|
||||
<strong>{$t('adventures.end')}:</strong>
|
||||
{DateTime.fromISO(visit.end_date, { zone: 'utc' })
|
||||
.setZone(visit.timezone)
|
||||
.toLocaleString(DateTime.DATETIME_MED)}
|
||||
{:else}
|
||||
<strong>Start:</strong>
|
||||
{DateTime.fromISO(visit.start_date).toLocaleString(
|
||||
DateTime.DATETIME_MED
|
||||
)}<br />
|
||||
<strong>End:</strong>
|
||||
{DateTime.fromISO(visit.end_date).toLocaleString(
|
||||
DateTime.DATETIME_MED
|
||||
)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if visit.notes}
|
||||
<div class="mt-3 p-3 bg-base-200 rounded-lg">
|
||||
<p class="text-sm italic">"{visit.notes}"</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Activities Section -->
|
||||
{#if visit.activities && visit.activities.length > 0}
|
||||
<div class="mt-4">
|
||||
<h4 class="font-semibold mb-3 flex items-center gap-2">
|
||||
🏃♂️ Activities ({visit.activities.length})
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
{#each visit.activities as activity}
|
||||
<ActivityCard
|
||||
{activity}
|
||||
readOnly={true}
|
||||
trails={adventure.trails}
|
||||
{visit}
|
||||
measurementSystem={data.user?.measurement_system || 'metric'}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Activities Section -->
|
||||
{#if visit.activities && visit.activities.length > 0}
|
||||
<div class="mt-4">
|
||||
<h4 class="font-semibold mb-3 flex items-center gap-2">
|
||||
🏃♂️ Activities ({visit.activities.length})
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
{#each visit.activities as activity}
|
||||
<ActivityCard
|
||||
{activity}
|
||||
readOnly={true}
|
||||
trails={adventure.trails}
|
||||
{visit}
|
||||
measurementSystem={data.user?.measurement_system ||
|
||||
'metric'}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visitDate && adventure.latitude && adventure.longitude}
|
||||
<div class="shrink-0 self-start pt-0.5">
|
||||
{#if visitSunriseSunset[visitDate]}
|
||||
{@const sunriseSunset = visitSunriseSunset[visitDate]}
|
||||
<div
|
||||
class="tooltip tooltip-left"
|
||||
data-tip="{$t(
|
||||
'adventures.sunrise'
|
||||
)}: {sunriseSunset.sunrise} • {$t(
|
||||
'adventures.sunset'
|
||||
)}: {sunriseSunset.sunset}"
|
||||
>
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm text-warning"
|
||||
type="button"
|
||||
aria-label="{$t(
|
||||
'adventures.sunrise'
|
||||
)}: {sunriseSunset.sunrise}, {$t(
|
||||
'adventures.sunset'
|
||||
)}: {sunriseSunset.sunset}"
|
||||
>
|
||||
<WeatherSunset class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{:else if sunriseSunsetLoading[visitDate]}
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label={$t('adventures.loading_sunrise_sunset')}
|
||||
>
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="tooltip tooltip-left"
|
||||
data-tip={$t('adventures.show_sunrise_sunset')}
|
||||
>
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm opacity-60 hover:opacity-100"
|
||||
type="button"
|
||||
aria-label={$t('adventures.show_sunrise_sunset')}
|
||||
on:click={() => loadSunriseSunsetForDate(visitDate)}
|
||||
>
|
||||
<WeatherSunset class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -948,30 +1042,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sunrise/Sunset -->
|
||||
{#if adventure.sun_times && adventure.sun_times.length > 0}
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">
|
||||
🌅 {$t('adventures.sun_times')}
|
||||
<WeatherSunset class="w-5 h-5" />
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{#each adventure.sun_times as sun_time}
|
||||
<div class="border-l-4 border-warning pl-3">
|
||||
<div class="font-semibold text-sm">
|
||||
{new Date(sun_time.date).toLocaleDateString()}
|
||||
</div>
|
||||
<div class="text-xs opacity-70">
|
||||
{$t('adventures.sunrise')}: {sun_time.sunrise} • {$t('adventures.sunset')}: {sun_time.sunset}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Attachments -->
|
||||
{#if adventure.attachments && adventure.attachments.length > 0}
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
|
||||
Reference in New Issue
Block a user