Skip to content

Commit efa7b5a

Browse files
wanooclaude
andcommitted
feat: Add currency system, collection templates, and unified search
- Add country currency fields and ExchangeRate model to worldtravel - Add CollectionTemplate model for reusable trip templates - Add template CRUD endpoints and frontend pages - Add CurrencyDropdown, MoneyInput, and price badge components - Add unified LocationSearchMap with reverse geocode integration - Add exchange rates store and money formatting utilities - Add AvgPriceBadge and PriceTierBadge shared card components - Default public_profile=True for users Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3b103d2 commit efa7b5a

File tree

23 files changed

+2646
-159
lines changed

23 files changed

+2646
-159
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated manually for CollectionTemplate model
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import uuid
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
('adventures', '0080_default_is_public_true'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='CollectionTemplate',
19+
fields=[
20+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
21+
('name', models.CharField(max_length=255)),
22+
('description', models.TextField(blank=True, null=True)),
23+
('template_data', models.JSONField(default=dict)),
24+
('is_public', models.BooleanField(default=False)),
25+
('created_at', models.DateTimeField(auto_now_add=True)),
26+
('updated_at', models.DateTimeField(auto_now=True)),
27+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_templates', to=settings.AUTH_USER_MODEL)),
28+
],
29+
),
30+
]

backend/server/adventures/views/reverse_geocode_view.py

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@
33
from rest_framework.permissions import IsAuthenticated
44
from rest_framework.response import Response
55
from worldtravel.models import Region, City, VisitedRegion, VisitedCity
6-
from adventures.models import Location
6+
from adventures.models import Location, Lodging, Transportation
77
from adventures.serializers import LocationSerializer
88
from adventures.geocoding import reverse_geocode
99
from django.conf import settings
10+
from django.db.models import Q
1011
from adventures.geocoding import search_google, search_osm
1112

13+
SEARCH_MODE_SUFFIXES = {
14+
'airport': ' Airport',
15+
'train': ' Station',
16+
'bus': ' Bus Station',
17+
'location': '',
18+
'cab': '',
19+
'vtc': '',
20+
}
21+
1222
class ReverseGeocodeViewSet(viewsets.ViewSet):
1323
permission_classes = [IsAuthenticated]
1424

@@ -37,12 +47,186 @@ def search(self, request):
3747
try:
3848
if getattr(settings, 'GOOGLE_MAPS_API_KEY', None):
3949
results = search_google(query)
50+
# Google returned error dict - fallback to OSM
51+
if isinstance(results, dict):
52+
results = search_osm(query)
4053
else:
4154
results = search_osm(query)
55+
# Final check: if still an error dict, return it as error
56+
if isinstance(results, dict):
57+
return Response(results, status=500)
4258
return Response(results)
4359
except Exception:
4460
return Response({"error": "An internal error occurred while processing the request"}, status=500)
4561

62+
@action(detail=False, methods=['get'])
63+
def unified_search(self, request):
64+
"""
65+
Unified search endpoint that combines:
66+
1. Geocoding results (addresses from Google Maps/OSM)
67+
2. User's own locations
68+
3. User's own lodgings
69+
4. User's transportation departures and arrivals
70+
71+
The search_mode parameter controls suffix appended to geocoding queries
72+
(e.g., 'airport' appends ' Airport') without affecting internal entity queries.
73+
74+
Returns grouped results by source type for intelligent autocomplete.
75+
"""
76+
query = request.query_params.get('query', '')
77+
search_mode = request.query_params.get('search_mode', 'location')
78+
include_geocode = request.query_params.get('include_geocode', 'true').lower() == 'true'
79+
include_locations = request.query_params.get('include_locations', 'true').lower() == 'true'
80+
include_lodging = request.query_params.get('include_lodging', 'true').lower() == 'true'
81+
include_transportation = request.query_params.get('include_transportation', 'true').lower() == 'true'
82+
83+
if not query or len(query) < 2:
84+
return Response({"error": "Query parameter must be at least 2 characters"}, status=400)
85+
86+
results = {
87+
"addresses": [],
88+
"locations": [],
89+
"lodging": [],
90+
"departures": [],
91+
"arrivals": []
92+
}
93+
94+
# Search user's locations (raw query, no suffix)
95+
if include_locations:
96+
locations = Location.objects.filter(
97+
Q(user=self.request.user) | Q(is_public=True),
98+
Q(name__icontains=query) | Q(location__icontains=query)
99+
).exclude(
100+
latitude__isnull=True
101+
).exclude(
102+
longitude__isnull=True
103+
).order_by('-updated_at')[:10]
104+
105+
results["locations"] = [
106+
{
107+
"id": str(loc.id),
108+
"name": loc.name,
109+
"display_name": loc.location or loc.name,
110+
"lat": float(loc.latitude),
111+
"lon": float(loc.longitude),
112+
"type": "location",
113+
"category": loc.category.name if loc.category else None,
114+
"source": "location"
115+
}
116+
for loc in locations
117+
]
118+
119+
# Search user's lodging (raw query, no suffix)
120+
if include_lodging:
121+
lodging = Lodging.objects.filter(
122+
Q(user=self.request.user) | Q(is_public=True),
123+
Q(name__icontains=query) | Q(location__icontains=query)
124+
).exclude(
125+
latitude__isnull=True
126+
).exclude(
127+
longitude__isnull=True
128+
).order_by('-updated_at')[:10]
129+
130+
results["lodging"] = [
131+
{
132+
"id": str(ldg.id),
133+
"name": ldg.name,
134+
"display_name": ldg.location or ldg.name,
135+
"lat": float(ldg.latitude),
136+
"lon": float(ldg.longitude),
137+
"type": ldg.type,
138+
"category": "lodging",
139+
"source": "lodging"
140+
}
141+
for ldg in lodging
142+
]
143+
144+
# Search user's transportation departures and arrivals (raw query, no suffix)
145+
if include_transportation:
146+
# Search departures (from_location)
147+
departures = Transportation.objects.filter(
148+
Q(user=self.request.user) | Q(is_public=True),
149+
Q(from_location__icontains=query) | Q(name__icontains=query)
150+
).exclude(
151+
origin_latitude__isnull=True
152+
).exclude(
153+
origin_longitude__isnull=True
154+
).order_by('-updated_at')[:10]
155+
156+
# Use a set to deduplicate by coordinates
157+
seen_departures = set()
158+
for t in departures:
159+
key = (float(t.origin_latitude), float(t.origin_longitude))
160+
if key not in seen_departures:
161+
seen_departures.add(key)
162+
results["departures"].append({
163+
"id": str(t.id),
164+
"name": t.from_location or t.name,
165+
"display_name": t.from_location or t.name,
166+
"lat": float(t.origin_latitude),
167+
"lon": float(t.origin_longitude),
168+
"type": t.type,
169+
"category": "departure",
170+
"source": "departure",
171+
"code": t.start_code or None
172+
})
173+
174+
# Search arrivals (to_location)
175+
arrivals = Transportation.objects.filter(
176+
Q(user=self.request.user) | Q(is_public=True),
177+
Q(to_location__icontains=query) | Q(name__icontains=query)
178+
).exclude(
179+
destination_latitude__isnull=True
180+
).exclude(
181+
destination_longitude__isnull=True
182+
).order_by('-updated_at')[:10]
183+
184+
# Use a set to deduplicate by coordinates
185+
seen_arrivals = set()
186+
for t in arrivals:
187+
key = (float(t.destination_latitude), float(t.destination_longitude))
188+
if key not in seen_arrivals:
189+
seen_arrivals.add(key)
190+
results["arrivals"].append({
191+
"id": str(t.id),
192+
"name": t.to_location or t.name,
193+
"display_name": t.to_location or t.name,
194+
"lat": float(t.destination_latitude),
195+
"lon": float(t.destination_longitude),
196+
"type": t.type,
197+
"category": "arrival",
198+
"source": "arrival",
199+
"code": t.end_code or None
200+
})
201+
202+
# Search addresses via geocoding (suffix applied here only)
203+
if include_geocode:
204+
try:
205+
geocode_query = query + SEARCH_MODE_SUFFIXES.get(search_mode, '')
206+
geocode_results = None
207+
208+
if getattr(settings, 'GOOGLE_MAPS_API_KEY', None):
209+
geocode_results = search_google(geocode_query)
210+
# Google returned error dict - fallback to OSM
211+
if isinstance(geocode_results, dict):
212+
geocode_results = search_osm(geocode_query)
213+
else:
214+
geocode_results = search_osm(geocode_query)
215+
216+
if isinstance(geocode_results, list):
217+
results["addresses"] = [
218+
{
219+
**r,
220+
"source": "address"
221+
}
222+
for r in geocode_results[:10]
223+
]
224+
except Exception:
225+
# Geocoding failed, but we can still return internal results
226+
pass
227+
228+
return Response(results)
229+
46230
@action(detail=False, methods=['post'])
47231
def mark_visited_region(self, request):
48232
"""
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
from django.db.models import Q
2+
from rest_framework import viewsets, status
3+
from rest_framework.decorators import action
4+
from rest_framework.response import Response
5+
from rest_framework.permissions import IsAuthenticated
6+
7+
from adventures.models import (
8+
CollectionTemplate, Collection, Transportation, Note, Checklist,
9+
ChecklistItem, Lodging, Location
10+
)
11+
from adventures.serializers import CollectionTemplateSerializer, CollectionSerializer
12+
13+
14+
class CollectionTemplateViewSet(viewsets.ModelViewSet):
15+
"""
16+
ViewSet for managing collection templates.
17+
18+
Supports:
19+
- List own templates + public templates
20+
- Retrieve template details
21+
- Delete own templates
22+
- Create collection from template
23+
"""
24+
serializer_class = CollectionTemplateSerializer
25+
permission_classes = [IsAuthenticated]
26+
27+
def get_queryset(self):
28+
"""Return user's own templates plus all public templates"""
29+
user = self.request.user
30+
return CollectionTemplate.objects.filter(
31+
Q(user=user) | Q(is_public=True)
32+
).distinct()
33+
34+
def perform_create(self, serializer):
35+
serializer.save(user=self.request.user)
36+
37+
def destroy(self, request, *args, **kwargs):
38+
"""Only allow deletion of own templates"""
39+
instance = self.get_object()
40+
if instance.user != request.user:
41+
return Response(
42+
{"error": "You can only delete your own templates"},
43+
status=status.HTTP_403_FORBIDDEN
44+
)
45+
return super().destroy(request, *args, **kwargs)
46+
47+
def update(self, request, *args, **kwargs):
48+
"""Only allow update of own templates"""
49+
instance = self.get_object()
50+
if instance.user != request.user:
51+
return Response(
52+
{"error": "You can only update your own templates"},
53+
status=status.HTTP_403_FORBIDDEN
54+
)
55+
return super().update(request, *args, **kwargs)
56+
57+
def partial_update(self, request, *args, **kwargs):
58+
"""Only allow partial update of own templates"""
59+
instance = self.get_object()
60+
if instance.user != request.user:
61+
return Response(
62+
{"error": "You can only update your own templates"},
63+
status=status.HTTP_403_FORBIDDEN
64+
)
65+
return super().partial_update(request, *args, **kwargs)
66+
67+
@action(detail=True, methods=['post'], url_path='create-collection')
68+
def create_collection(self, request, pk=None):
69+
"""
70+
Create a new collection from a template.
71+
72+
The template_data contains structure for notes, checklists,
73+
transportations, and lodgings that will be created in the new collection.
74+
"""
75+
template = self.get_object()
76+
user = request.user
77+
78+
# Get optional name override from request
79+
collection_name = request.data.get('name', template.name)
80+
collection_description = request.data.get('description', template.description)
81+
82+
# Create the new collection
83+
new_collection = Collection.objects.create(
84+
name=collection_name,
85+
description=collection_description,
86+
is_public=False, # New collections from templates are always private
87+
user=user,
88+
)
89+
90+
template_data = template.template_data or {}
91+
92+
# Link locations from template (only those the user has access to)
93+
location_ids = template_data.get('locations', [])
94+
if location_ids:
95+
# Get locations that the user owns or are public
96+
accessible_locations = Location.objects.filter(
97+
Q(id__in=location_ids) & (Q(user=user) | Q(is_public=True))
98+
)
99+
new_collection.locations.set(accessible_locations)
100+
101+
# Create notes from template
102+
for note_data in template_data.get('notes', []):
103+
Note.objects.create(
104+
user=user,
105+
collection=new_collection,
106+
name=note_data.get('name', 'Untitled Note'),
107+
content=note_data.get('content', ''),
108+
links=note_data.get('links', []),
109+
is_public=False,
110+
)
111+
112+
# Create checklists from template
113+
for checklist_data in template_data.get('checklists', []):
114+
checklist = Checklist.objects.create(
115+
user=user,
116+
collection=new_collection,
117+
name=checklist_data.get('name', 'Untitled Checklist'),
118+
is_public=False,
119+
)
120+
# Create checklist items
121+
for item_data in checklist_data.get('items', []):
122+
ChecklistItem.objects.create(
123+
user=user,
124+
checklist=checklist,
125+
name=item_data.get('name', ''),
126+
is_checked=False, # Always start unchecked
127+
)
128+
129+
# Create transportations from template
130+
for transport_data in template_data.get('transportations', []):
131+
Transportation.objects.create(
132+
user=user,
133+
collection=new_collection,
134+
type=transport_data.get('type', 'other'),
135+
name=transport_data.get('name', 'Untitled Transportation'),
136+
description=transport_data.get('description', ''),
137+
from_location=transport_data.get('from_location', ''),
138+
to_location=transport_data.get('to_location', ''),
139+
is_public=False,
140+
)
141+
142+
# Create lodgings from template
143+
for lodging_data in template_data.get('lodgings', []):
144+
Lodging.objects.create(
145+
user=user,
146+
collection=new_collection,
147+
type=lodging_data.get('type', 'other'),
148+
name=lodging_data.get('name', 'Untitled Lodging'),
149+
description=lodging_data.get('description', ''),
150+
location=lodging_data.get('location', ''),
151+
is_public=False,
152+
)
153+
154+
serializer = CollectionSerializer(new_collection, context={'request': request})
155+
return Response(serializer.data, status=status.HTTP_201_CREATED)

0 commit comments

Comments
 (0)