| 1 |
from django.http import JsonResponse |
| 2 |
from django.utils import timezone |
| 3 |
from django.conf import settings |
| 4 |
from django.db.models import Q |
| 5 |
from rest_framework import status, generics |
| 6 |
from rest_framework.decorators import api_view |
| 7 |
from rest_framework.response import Response |
| 8 |
from rest_framework.views import APIView |
| 9 |
import requests |
| 10 |
import math |
| 11 |
from .models import Restaurant, Rating |
| 12 |
from .serializers import ( |
| 13 |
RestaurantSerializer, RestaurantDetailSerializer, |
| 14 |
CreateRatingSerializer, RatingSerializer, PlaceSearchSerializer |
| 15 |
) |
| 16 |
|
| 17 |
|
| 18 |
def health_check(request): |
| 19 |
"""Simple health check endpoint""" |
| 20 |
return JsonResponse({ |
| 21 |
'status': 'LocalToast is cooking! 🍞', |
| 22 |
'timestamp': timezone.now().isoformat(), |
| 23 |
'version': '2.0' |
| 24 |
}) |
| 25 |
|
| 26 |
|
| 27 |
def calculate_distance(lat1, lon1, lat2, lon2): |
| 28 |
"""Calculate distance between two points in kilometers""" |
| 29 |
R = 6371 # Radius of Earth in km |
| 30 |
dlat = math.radians(lat2 - lat1) |
| 31 |
dlon = math.radians(lon2 - lon1) |
| 32 |
a = (math.sin(dlat/2) * math.sin(dlat/2) + |
| 33 |
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * |
| 34 |
math.sin(dlon/2) * math.sin(dlon/2)) |
| 35 |
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) |
| 36 |
return R * c |
| 37 |
|
| 38 |
|
| 39 |
class NearbyRestaurantsView(APIView): |
| 40 |
"""Get restaurants near a location""" |
| 41 |
|
| 42 |
def get(self, request): |
| 43 |
lat = request.query_params.get('lat') |
| 44 |
lng = request.query_params.get('lng') |
| 45 |
radius = float(request.query_params.get('radius', 5)) # km |
| 46 |
|
| 47 |
if not lat or not lng: |
| 48 |
return Response( |
| 49 |
{'error': 'Latitude and longitude are required'}, |
| 50 |
status=status.HTTP_400_BAD_REQUEST |
| 51 |
) |
| 52 |
|
| 53 |
try: |
| 54 |
lat = float(lat) |
| 55 |
lng = float(lng) |
| 56 |
except ValueError: |
| 57 |
return Response( |
| 58 |
{'error': 'Invalid latitude or longitude'}, |
| 59 |
status=status.HTTP_400_BAD_REQUEST |
| 60 |
) |
| 61 |
|
| 62 |
# Simple bounding box calculation |
| 63 |
lat_diff = radius / 111 # 1 degree latitude ≈ 111 km |
| 64 |
lng_diff = radius / (111 * math.cos(math.radians(lat))) |
| 65 |
|
| 66 |
restaurants = Restaurant.objects.filter( |
| 67 |
latitude__range=(lat - lat_diff, lat + lat_diff), |
| 68 |
longitude__range=(lng - lng_diff, lng + lng_diff) |
| 69 |
) |
| 70 |
|
| 71 |
# Calculate actual distances and filter |
| 72 |
results = [] |
| 73 |
for restaurant in restaurants: |
| 74 |
distance = calculate_distance( |
| 75 |
lat, lng, |
| 76 |
restaurant.latitude, |
| 77 |
restaurant.longitude |
| 78 |
) |
| 79 |
if distance <= radius: |
| 80 |
serializer = RestaurantSerializer(restaurant) |
| 81 |
data = serializer.data |
| 82 |
data['distance'] = round(distance, 2) |
| 83 |
results.append(data) |
| 84 |
|
| 85 |
# Sort by distance |
| 86 |
results.sort(key=lambda x: x['distance']) |
| 87 |
|
| 88 |
return Response(results) |
| 89 |
|
| 90 |
|
| 91 |
class RestaurantListCreateView(generics.ListCreateAPIView): |
| 92 |
"""List all restaurants or create a new one""" |
| 93 |
queryset = Restaurant.objects.all() |
| 94 |
serializer_class = RestaurantSerializer |
| 95 |
|
| 96 |
def create(self, request, *args, **kwargs): |
| 97 |
# Check if restaurant already exists |
| 98 |
place_id = request.data.get('place_id') |
| 99 |
if place_id: |
| 100 |
existing = Restaurant.objects.filter(place_id=place_id).first() |
| 101 |
if existing: |
| 102 |
serializer = self.get_serializer(existing) |
| 103 |
return Response(serializer.data, status=status.HTTP_200_OK) |
| 104 |
|
| 105 |
return super().create(request, *args, **kwargs) |
| 106 |
|
| 107 |
|
| 108 |
class RestaurantDetailView(generics.RetrieveAPIView): |
| 109 |
"""Get detailed info about a restaurant""" |
| 110 |
queryset = Restaurant.objects.all() |
| 111 |
serializer_class = RestaurantDetailSerializer |
| 112 |
|
| 113 |
|
| 114 |
class RestaurantRatingView(APIView): |
| 115 |
"""Add a rating to a restaurant or get all ratings""" |
| 116 |
|
| 117 |
def get(self, request, pk): |
| 118 |
try: |
| 119 |
restaurant = Restaurant.objects.get(pk=pk) |
| 120 |
except Restaurant.DoesNotExist: |
| 121 |
return Response( |
| 122 |
{'error': 'Restaurant not found'}, |
| 123 |
status=status.HTTP_404_NOT_FOUND |
| 124 |
) |
| 125 |
|
| 126 |
ratings = restaurant.ratings.all() |
| 127 |
serializer = RatingSerializer(ratings, many=True) |
| 128 |
return Response(serializer.data) |
| 129 |
|
| 130 |
def post(self, request, pk): |
| 131 |
try: |
| 132 |
restaurant = Restaurant.objects.get(pk=pk) |
| 133 |
except Restaurant.DoesNotExist: |
| 134 |
return Response( |
| 135 |
{'error': 'Restaurant not found'}, |
| 136 |
status=status.HTTP_404_NOT_FOUND |
| 137 |
) |
| 138 |
|
| 139 |
serializer = CreateRatingSerializer(data=request.data) |
| 140 |
if serializer.is_valid(): |
| 141 |
rating = serializer.save(restaurant=restaurant) |
| 142 |
return Response( |
| 143 |
{ |
| 144 |
'id': rating.id, |
| 145 |
'message': 'Toast rating added successfully! 🍞' |
| 146 |
}, |
| 147 |
status=status.HTTP_201_CREATED |
| 148 |
) |
| 149 |
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) |
| 150 |
|
| 151 |
|
| 152 |
class RestaurantToastStatusView(APIView): |
| 153 |
"""Update restaurant's toast status""" |
| 154 |
|
| 155 |
def patch(self, request, pk): |
| 156 |
try: |
| 157 |
restaurant = Restaurant.objects.get(pk=pk) |
| 158 |
except Restaurant.DoesNotExist: |
| 159 |
return Response( |
| 160 |
{'error': 'Restaurant not found'}, |
| 161 |
status=status.HTTP_404_NOT_FOUND |
| 162 |
) |
| 163 |
|
| 164 |
has_toast = request.data.get('has_toast') |
| 165 |
if has_toast is None: |
| 166 |
return Response( |
| 167 |
{'error': 'has_toast field is required'}, |
| 168 |
status=status.HTTP_400_BAD_REQUEST |
| 169 |
) |
| 170 |
|
| 171 |
restaurant.has_toast = has_toast |
| 172 |
restaurant.save() |
| 173 |
|
| 174 |
return Response({ |
| 175 |
'id': restaurant.id, |
| 176 |
'has_toast': restaurant.has_toast, |
| 177 |
'message': f'Toast status updated! {"🍞" if has_toast else "❌"}' |
| 178 |
}) |
| 179 |
|
| 180 |
|
| 181 |
class SearchPlacesView(APIView): |
| 182 |
"""Search for places that might serve toast""" |
| 183 |
|
| 184 |
def get(self, request): |
| 185 |
lat = request.query_params.get('lat') |
| 186 |
lng = request.query_params.get('lng') |
| 187 |
radius = request.query_params.get('radius', '1000') |
| 188 |
|
| 189 |
if not lat or not lng: |
| 190 |
return Response( |
| 191 |
{'error': 'Latitude and longitude are required'}, |
| 192 |
status=status.HTTP_400_BAD_REQUEST |
| 193 |
) |
| 194 |
|
| 195 |
places = [] |
| 196 |
|
| 197 |
# Try Google Places API first if available |
| 198 |
if settings.GOOGLE_PLACES_API_KEY: |
| 199 |
try: |
| 200 |
google_results = self.search_google_places(lat, lng, radius) |
| 201 |
places.extend(google_results) |
| 202 |
except Exception as e: |
| 203 |
print(f"Google Places API error: {e}") |
| 204 |
|
| 205 |
# If no results from Google, try OpenStreetMap |
| 206 |
if not places: |
| 207 |
try: |
| 208 |
osm_results = self.search_openstreetmap(lat, lng, radius) |
| 209 |
places.extend(osm_results) |
| 210 |
except Exception as e: |
| 211 |
print(f"OpenStreetMap error: {e}") |
| 212 |
|
| 213 |
# If still no results, return mock data |
| 214 |
if not places: |
| 215 |
places = self.get_mock_places(float(lat), float(lng)) |
| 216 |
|
| 217 |
# Calculate distances and sort |
| 218 |
user_lat, user_lng = float(lat), float(lng) |
| 219 |
for place in places: |
| 220 |
place['distance'] = calculate_distance( |
| 221 |
user_lat, user_lng, |
| 222 |
place['latitude'], place['longitude'] |
| 223 |
) |
| 224 |
|
| 225 |
places.sort(key=lambda x: x['distance']) |
| 226 |
|
| 227 |
# Auto-create restaurants for all found places |
| 228 |
created_restaurants = [] |
| 229 |
for place in places[:20]: # Limit to top 20 |
| 230 |
restaurant, created = Restaurant.objects.get_or_create( |
| 231 |
place_id=place['place_id'], |
| 232 |
defaults={ |
| 233 |
'name': place['name'], |
| 234 |
'address': place['address'], |
| 235 |
'latitude': place['latitude'], |
| 236 |
'longitude': place['longitude'], |
| 237 |
'has_toast': None # Unknown status initially |
| 238 |
} |
| 239 |
) |
| 240 |
# Add restaurant data to place info |
| 241 |
place['restaurant_id'] = restaurant.id |
| 242 |
place['has_toast'] = restaurant.has_toast |
| 243 |
created_restaurants.append(place) |
| 244 |
|
| 245 |
serializer = PlaceSearchSerializer(created_restaurants, many=True) |
| 246 |
return Response(serializer.data) |
| 247 |
|
| 248 |
def search_google_places(self, lat, lng, radius): |
| 249 |
"""Search using Google Places API""" |
| 250 |
url = 'https://maps.googleapis.com/maps/api/place/textsearch/json' |
| 251 |
params = { |
| 252 |
'query': 'cafe bakery breakfast toast', |
| 253 |
'location': f'{lat},{lng}', |
| 254 |
'radius': radius, |
| 255 |
'type': 'restaurant|cafe|bakery', |
| 256 |
'key': settings.GOOGLE_PLACES_API_KEY |
| 257 |
} |
| 258 |
|
| 259 |
response = requests.get(url, params=params, timeout=10) |
| 260 |
response.raise_for_status() |
| 261 |
data = response.json() |
| 262 |
|
| 263 |
places = [] |
| 264 |
for place in data.get('results', []): |
| 265 |
confidence = 'high' if 'toast' in place['name'].lower() else 'medium' |
| 266 |
if any(word in place['name'].lower() for word in ['cafe', 'bakery', 'breakfast']): |
| 267 |
confidence = 'high' |
| 268 |
|
| 269 |
places.append({ |
| 270 |
'place_id': place['place_id'], |
| 271 |
'name': place['name'], |
| 272 |
'address': place.get('formatted_address', place.get('vicinity', '')), |
| 273 |
'latitude': place['geometry']['location']['lat'], |
| 274 |
'longitude': place['geometry']['location']['lng'], |
| 275 |
'category': 'restaurant', |
| 276 |
'confidence': confidence, |
| 277 |
'source': 'google_places' |
| 278 |
}) |
| 279 |
|
| 280 |
return places |
| 281 |
|
| 282 |
def search_openstreetmap(self, lat, lng, radius): |
| 283 |
"""Search using OpenStreetMap Overpass API""" |
| 284 |
overpass_query = f""" |
| 285 |
[out:json][timeout:25]; |
| 286 |
( |
| 287 |
node["amenity"="cafe"](around:{radius},{lat},{lng}); |
| 288 |
node["shop"="bakery"](around:{radius},{lat},{lng}); |
| 289 |
node["amenity"="restaurant"]["cuisine"~"breakfast"](around:{radius},{lat},{lng}); |
| 290 |
); |
| 291 |
out body; |
| 292 |
""" |
| 293 |
|
| 294 |
url = 'https://overpass-api.de/api/interpreter' |
| 295 |
response = requests.post( |
| 296 |
url, |
| 297 |
data={'data': overpass_query}, |
| 298 |
timeout=10 |
| 299 |
) |
| 300 |
response.raise_for_status() |
| 301 |
data = response.json() |
| 302 |
|
| 303 |
places = [] |
| 304 |
for element in data.get('elements', []): |
| 305 |
if not element.get('tags', {}).get('name'): |
| 306 |
continue |
| 307 |
|
| 308 |
tags = element['tags'] |
| 309 |
category = tags.get('amenity') or tags.get('shop', 'unknown') |
| 310 |
|
| 311 |
# Determine confidence |
| 312 |
confidence = 'low' |
| 313 |
if category in ['cafe', 'bakery']: |
| 314 |
confidence = 'medium' |
| 315 |
if 'breakfast' in tags.get('cuisine', '').lower(): |
| 316 |
confidence = 'high' |
| 317 |
|
| 318 |
places.append({ |
| 319 |
'place_id': f"osm_{element['id']}", |
| 320 |
'name': tags['name'], |
| 321 |
'address': self.format_osm_address(tags), |
| 322 |
'latitude': element['lat'], |
| 323 |
'longitude': element['lon'], |
| 324 |
'category': category, |
| 325 |
'cuisine': tags.get('cuisine', ''), |
| 326 |
'confidence': confidence, |
| 327 |
'source': 'openstreetmap' |
| 328 |
}) |
| 329 |
|
| 330 |
return places |
| 331 |
|
| 332 |
def format_osm_address(self, tags): |
| 333 |
"""Format address from OSM tags""" |
| 334 |
parts = [] |
| 335 |
for key in ['addr:housenumber', 'addr:street', 'addr:city']: |
| 336 |
if key in tags: |
| 337 |
parts.append(tags[key]) |
| 338 |
return ' '.join(parts) if parts else 'Address not available' |
| 339 |
|
| 340 |
def get_mock_places(self, lat, lng): |
| 341 |
"""Return mock data as fallback""" |
| 342 |
return [ |
| 343 |
{ |
| 344 |
'place_id': 'mock_1', |
| 345 |
'name': 'The Breakfast Club', |
| 346 |
'address': '123 Toast Lane', |
| 347 |
'latitude': lat + 0.01, |
| 348 |
'longitude': lng + 0.01, |
| 349 |
'category': 'restaurant', |
| 350 |
'confidence': 'low', |
| 351 |
'source': 'mock' |
| 352 |
} |
| 353 |
] |
| 354 |
|
| 355 |
|
| 356 |
@api_view(['POST']) |
| 357 |
def seed_data(request): |
| 358 |
"""Seed the database with sample restaurants""" |
| 359 |
lat = float(request.data.get('lat', 40.7128)) |
| 360 |
lng = float(request.data.get('lng', -74.0060)) |
| 361 |
|
| 362 |
seed_restaurants = [ |
| 363 |
{ |
| 364 |
'place_id': 'seed_1', |
| 365 |
'name': 'Toast & Jam Café', |
| 366 |
'address': '100 Breakfast Blvd', |
| 367 |
'latitude': lat + 0.005, |
| 368 |
'longitude': lng + 0.005 |
| 369 |
}, |
| 370 |
{ |
| 371 |
'place_id': 'seed_2', |
| 372 |
'name': 'The Golden Toast', |
| 373 |
'address': '200 Butter Lane', |
| 374 |
'latitude': lat - 0.005, |
| 375 |
'longitude': lng + 0.005 |
| 376 |
}, |
| 377 |
{ |
| 378 |
'place_id': 'seed_3', |
| 379 |
'name': 'Morning Glory Diner', |
| 380 |
'address': '300 Sunrise Ave', |
| 381 |
'latitude': lat + 0.005, |
| 382 |
'longitude': lng - 0.005 |
| 383 |
}, |
| 384 |
{ |
| 385 |
'place_id': 'seed_4', |
| 386 |
'name': 'Crispy Corner', |
| 387 |
'address': '400 Crunch St', |
| 388 |
'latitude': lat - 0.005, |
| 389 |
'longitude': lng - 0.005 |
| 390 |
} |
| 391 |
] |
| 392 |
|
| 393 |
created = 0 |
| 394 |
for restaurant_data in seed_restaurants: |
| 395 |
restaurant, was_created = Restaurant.objects.get_or_create( |
| 396 |
place_id=restaurant_data['place_id'], |
| 397 |
defaults=restaurant_data |
| 398 |
) |
| 399 |
if was_created: |
| 400 |
created += 1 |
| 401 |
|
| 402 |
return Response({ |
| 403 |
'message': f'Seeded {created} restaurants with toast! 🍞', |
| 404 |
'total_restaurants': Restaurant.objects.count() |
| 405 |
}) |