• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gcivil-nyu-org / team1-mon-spring26 / 261

05 May 2026 11:12PM UTC coverage: 86.506% (-1.7%) from 88.245%
261

Pull #157

travis-pro

web-flow
Merge e77e0869a into da389439e
Pull Request #157: fix for s3 signed urls

200 of 272 new or added lines in 4 files covered. (73.53%)

1 existing line in 1 file now uncovered.

1218 of 1408 relevant lines covered (86.51%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.92
/maps/views.py
1
from django.conf import settings
1✔
2
from django.shortcuts import render
1✔
3
from django.http import JsonResponse
1✔
4
from django.http.multipartparser import MultiPartParser, MultiPartParserError
1✔
5
from django.views.decorators.http import require_http_methods
1✔
6
from django.views.decorators.csrf import csrf_exempt
1✔
7
from django.contrib.auth import authenticate, login, logout
1✔
8
from django.contrib.auth import update_session_auth_hash
1✔
9
from django.contrib.auth.decorators import login_required
1✔
10
from django.contrib.auth.password_validation import validate_password
1✔
11
from django.db import transaction
1✔
12
from django.core.exceptions import ValidationError
1✔
13
from django.views.decorators.cache import cache_control
1✔
14
from django.utils import timezone
1✔
15
from datetime import timedelta
1✔
16
import json
1✔
17
from .models import (
1✔
18
    AmenityType,
19
    Amenity,
20
    Review,
21
    AmenityPhoto,
22
    CustomUser,
23
    Chat,
24
    ChatParticipant,
25
    Message,
26
    ReviewVote,
27
    Favorite,
28
)
29
from django.db.models import (
1✔
30
    Count,
31
    Sum,
32
    Q,
33
    Value,
34
    IntegerField,
35
)
36
from django.db.models.functions import Coalesce
1✔
37
import os
1✔
38
import requests
1✔
39
import io
1✔
40
from PIL import Image, ImageOps
1✔
41
from django.core.files.uploadedfile import InMemoryUploadedFile
1✔
42

43
import boto3
1✔
44
from boto3.dynamodb.conditions import Key, Attr
1✔
45
import geohash2
1✔
46
import concurrent.futures
1✔
47
from collections import defaultdict
1✔
48

49
AVAILABILITY_WINDOW_HOURS = 3
1✔
50

51

52
def get_dynamodb_resource():
1✔
53
    kwargs = {"region_name": settings.DYNAMODB_REGION}
1✔
54
    if getattr(settings, "DYNAMODB_ENDPOINT_URL", None):
1✔
NEW
55
        kwargs["endpoint_url"] = settings.DYNAMODB_ENDPOINT_URL
×
56
    return boto3.resource("dynamodb", **kwargs)
1✔
57

58

59
def normalize_longitude(lon):
1✔
60
    """Normalize a longitude to the range [-180, 180]."""
61
    while lon < -180:
1✔
62
        lon += 360
1✔
63
    while lon > 180:
1✔
64
        lon -= 360
1✔
65
    return lon
1✔
66

67

68
def compress_image(uploaded_file, max_dimension=1024, quality=80):
1✔
69
    """
70
    Resizes and compresses an uploaded image to save storage and bandwidth.
71
    Converts the image to JPEG format.
72
    """
73
    try:
1✔
74
        img = Image.open(uploaded_file)
1✔
75

76
        # Preserve original EXIF orientation (prevent sideways mobile photos)
77
        img = ImageOps.exif_transpose(img)
×
78

79
        # Convert to RGB to ensure we can save it as JPEG
80
        if img.mode in ("RGBA", "P", "LA"):
×
81
            background = Image.new("RGB", img.size, (255, 255, 255))
×
82
            if img.mode == "RGBA":
×
83
                background.paste(img, mask=img.split()[3])
×
84
            elif img.mode == "LA":
×
85
                background.paste(img, mask=img.split()[1])
×
86
            else:
87
                background.paste(img)
×
88
            img = background
×
89
        elif img.mode != "RGB":
×
90
            img = img.convert("RGB")
×
91

92
        # Resize maintaining aspect ratio
93
        resample_filter = getattr(
×
94
            Image.Resampling, "LANCZOS", getattr(Image, "LANCZOS", 1)
95
        )
96
        img.thumbnail((max_dimension, max_dimension), resample_filter)
×
97

98
        # Save to buffer
99
        output = io.BytesIO()
×
100
        img.save(output, format="JPEG", quality=quality, optimize=True)
×
101
        output.seek(0)
×
102

103
        # Replace extension with .jpg
104
        base_name, _ = os.path.splitext(uploaded_file.name)
×
105
        new_name = f"{base_name}.jpg"
×
106

107
        return InMemoryUploadedFile(
×
108
            output,
109
            "ImageField",
110
            new_name,
111
            "image/jpeg",
112
            output.getbuffer().nbytes,
113
            None,
114
        )
115
    except Exception:
1✔
116
        # If anything fails (e.g. invalid file),
117
        # return original to let size validation handle it
118
        return uploaded_file
1✔
119

120

121
def map_view(request):
1✔
122
    """Render the main map view."""
123
    amenity_types = AmenityType.objects.all()
1✔
124
    return render(
1✔
125
        request,
126
        "maps/map.html",
127
        {
128
            "amenity_types": amenity_types,
129
            "app_release": settings.APP_RELEASE,
130
        },
131
    )
132

133

134
@login_required(login_url="/?auth_required=1")
1✔
135
def chats_view(request):
1✔
136
    """Render the chats view for messaging."""
137
    return render(request, "maps/chats.html")
1✔
138

139

140
def get_cluster_grid_size(zoom):
1✔
141
    """
142
    Determine the grid size for clustering based on the map's zoom level.
143
    A smaller grid size (larger denominator) means more, smaller clusters.
144
    A larger grid size (smaller denominator) means fewer, larger clusters.
145
    These values can be tuned for best visual results.
146
    """
147
    if zoom < 5:
1✔
148
        return 0.1  # Very large clusters for continental view
1✔
149
    if zoom < 7:
1✔
150
        return 0.075
1✔
151
    if zoom < 9:
1✔
152
        return 0.05
1✔
153
    if zoom < 11:
1✔
154
        return 0.04
1✔
155
    if zoom < 13:
1✔
156
        return 0.03
1✔
157
    if zoom < 14:
1✔
158
        return 0.02
1✔
159
    if zoom < 15:
1✔
160
        return 0.01
1✔
161
    if zoom < 16:
1✔
162
        return 0.005
1✔
163
    if zoom < 17:
1✔
164
        return 0.002
1✔
165
    return 0.001  # Very small clusters for the highest zoom level
1✔
166

167

168
def get_geohashes_in_bbox(north, south, east, west, precision=6):
1✔
169
    """Calculates all geohashes of a given precision that intersect a bounding box."""
170
    hashes = set()
1✔
171
    lat_step = 0.005  # Approximate step for precision 6
1✔
172
    lon_step = 0.01
1✔
173

174
    lat = float(south)
1✔
175
    while lat <= float(north) + lat_step:
1✔
176
        lon = float(west)
1✔
177
        while lon <= float(east) + lon_step:
1✔
178
            hashes.add(geohash2.encode(lat, lon, precision))
1✔
179
            lon += lon_step
1✔
180
        lat += lat_step
1✔
181

182
    return list(hashes)
1✔
183

184

185
def cluster_amenities_python(amenities_list, zoom):
1✔
186
    """Performs grid-based clustering natively in Python."""
187
    grid_size = get_cluster_grid_size(zoom)
1✔
188
    clusters = defaultdict(list)
1✔
189

190
    for amenity in amenities_list:
1✔
191
        lat = float(amenity["Latitude"])
1✔
192
        lon = float(amenity["Longitude"])
1✔
193

194
        snapped_lat = round(lat / grid_size) * grid_size
1✔
195
        snapped_lon = round(lon / grid_size) * grid_size
1✔
196

197
        clusters[(snapped_lat, snapped_lon)].append(amenity)
1✔
198

199
    result = []
1✔
200
    for (snapped_lat, snapped_lon), items in clusters.items():
1✔
201
        count = len(items)
1✔
202
        centroid_lat = sum(float(i["Latitude"]) for i in items) / count
1✔
203
        centroid_lon = sum(float(i["Longitude"]) for i in items) / count
1✔
204

205
        result.append(([i["Id"] for i in items], count, centroid_lat, centroid_lon))
1✔
206

207
    return result
1✔
208

209

210
def get_review_prefetch_queryset(user):
1✔
211
    queryset = (
1✔
212
        Review.objects.select_related("user")
213
        .annotate(
214
            vote_score=Coalesce(
215
                Sum("votes__value"), Value(0), output_field=IntegerField()
216
            ),
217
            upvote_count=Count("votes", filter=Q(votes__value=1)),
218
            downvote_count=Count("votes", filter=Q(votes__value=-1)),
219
        )
220
        .order_by("-vote_score", "-created_at")
221
    )
222
    if user.is_authenticated:
1✔
223
        queryset = queryset.annotate(
1✔
224
            user_vote=Coalesce(
225
                Sum(
226
                    "votes__value",
227
                    filter=Q(votes__user=user),
228
                ),
229
                Value(0),
230
                output_field=IntegerField(),
231
            )
232
        )
233
    return queryset
1✔
234

235

236
@cache_control(public=True, max_age=300)
1✔
237
def amenities_api(request):
1✔
238
    """API endpoint to fetch amenities from DynamoDB,
239
    optionally filtered by type and bounding box."""
240
    amenity_type_name = request.GET.get("type")
1✔
241
    include_inactive = request.GET.get("include_inactive", "false").lower() == "true"
1✔
242
    only_accessible = request.GET.get("only_accessible", "false").lower() == "true"
1✔
243
    zoom = int(request.GET.get("zoom", 0))
1✔
244

245
    dynamodb = get_dynamodb_resource()
1✔
246
    table = dynamodb.Table(settings.DYNAMODB_TABLE_NAME)
1✔
247

248
    amenities_data = []
1✔
249

250
    try:
1✔
251
        north = request.GET.get("north")
1✔
252
        south = request.GET.get("south")
1✔
253
        east = request.GET.get("east")
1✔
254
        west = request.GET.get("west")
1✔
255

256
        try:
1✔
257
            if north and south and east and west:
1✔
258
                north_f = float(north)
1✔
259
                south_f = float(south)
1✔
260
                east_f = float(east)
1✔
261
                west_f = float(west)
1✔
262
                hashes = get_geohashes_in_bbox(
1✔
263
                    north_f, south_f, east_f, west_f, precision=6
264
                )
265
            else:
266
                hashes = None
1✔
267
        except (TypeError, ValueError):
1✔
268
            hashes = None
1✔
269

270
        if hashes is not None:
1✔
271

272
            def fetch_hash(h):
1✔
273
                try:
1✔
274
                    response = table.query(
1✔
275
                        IndexName="GeohashIndex",
276
                        KeyConditionExpression=Key("GSI1PK").eq(f"GEOHASH#{h}"),
277
                    )
278
                    return response.get("Items", [])
1✔
NEW
279
                except Exception as e:
×
NEW
280
                    print(f"DynamoDB Query Error: {e}")
×
NEW
281
                    return []
×
282

283
            # Execute DynamoDB queries concurrently
284
            with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
1✔
285
                results = executor.map(fetch_hash, hashes)
1✔
286
                for res in results:
1✔
287
                    amenities_data.extend(res)
1✔
288
        else:
289
            # Scan fallback for broad non-bounds requests
290
            response = table.scan(FilterExpression=Attr("PK").begins_with("AMENITY#"))
1✔
291
            amenities_data = response.get("Items", [])
1✔
292
            while "LastEvaluatedKey" in response:
1✔
NEW
293
                response = table.scan(
×
294
                    FilterExpression=Attr("PK").begins_with("AMENITY#"),
295
                    ExclusiveStartKey=response["LastEvaluatedKey"],
296
                )
NEW
297
                amenities_data.extend(response.get("Items", []))
×
NEW
298
    except Exception as e:
×
NEW
299
        print("Error retrieving from DynamoDB:", str(e))
×
NEW
300
        return JsonResponse({"error": str(e)}, status=500)
×
301

302
    # Deduplicate overlapping geohash results
303
    seen_ids = set()
1✔
304
    unique_amenities = []
1✔
305
    for item in amenities_data:
1✔
306
        if item["Id"] not in seen_ids:
1✔
307
            seen_ids.add(item["Id"])
1✔
308
            unique_amenities.append(item)
1✔
309

310
    amenity_types_map = {t.name: t for t in AmenityType.objects.all()}
1✔
311

312
    # In-Memory Single-Table Design Filtering
313
    filtered_amenities = []
1✔
314
    for item in unique_amenities:
1✔
315
        if not include_inactive and not item.get("Active", True):
1✔
316
            continue
1✔
317
        if amenity_type_name and item.get("Type") != amenity_type_name:
1✔
318
            continue
1✔
319
        if only_accessible and item.get("Accessibility", "") == "Not Accessible":
1✔
320
            continue
1✔
321
        filtered_amenities.append(item)
1✔
322

323
    BIKE_RACK_TYPE_NAME = "Bike Rack"
1✔
324
    CLUSTER_ZOOM_THRESHOLD = 18
1✔
325
    final_amenities_list = []
1✔
326

327
    bike_rack_amenities = [
1✔
328
        a for a in filtered_amenities if a.get("Type") == BIKE_RACK_TYPE_NAME
329
    ]
330
    other_amenities = [
1✔
331
        a for a in filtered_amenities if a.get("Type") != BIKE_RACK_TYPE_NAME
332
    ]
333

334
    is_bike_rack_query = (
1✔
335
        amenity_type_name == BIKE_RACK_TYPE_NAME
336
    ) or not amenity_type_name
337

338
    if is_bike_rack_query and zoom < CLUSTER_ZOOM_THRESHOLD:
1✔
339
        clusters = cluster_amenities_python(bike_rack_amenities, zoom)
1✔
340

341
        for ids, count, centroid_lat, centroid_lon in clusters:
1✔
342
            if count > 1:
1✔
343
                amenity_type_obj = amenity_types_map.get(BIKE_RACK_TYPE_NAME)
1✔
344
                final_amenities_list.append(
1✔
345
                    {
346
                        "id": f"cluster_{centroid_lat}_{centroid_lon}",
347
                        "is_cluster": True,
348
                        "point_count": count,
349
                        "latitude": centroid_lat,
350
                        "longitude": centroid_lon,
351
                        "type": BIKE_RACK_TYPE_NAME,
352
                        "type_id": amenity_type_obj.id if amenity_type_obj else None,
353
                        "icon": (
354
                            amenity_type_obj.icon if amenity_type_obj else "bicycle"
355
                        ),
356
                        "color": (
357
                            amenity_type_obj.color if amenity_type_obj else "#FF9800"
358
                        ),
359
                        "is_favorited": False,
360
                    }
361
                )
362
            else:
363
                single_id = ids[0]
1✔
364
                single_item = next(
1✔
365
                    (a for a in bike_rack_amenities if a["Id"] == single_id), None
366
                )
367
                if single_item:
1✔
368
                    other_amenities.append(single_item)
1✔
369
    elif is_bike_rack_query:
1✔
370
        other_amenities.extend(bike_rack_amenities)
1✔
371

372
    for a in other_amenities:
1✔
373
        amenity_type_obj = amenity_types_map.get(a.get("Type", ""))
1✔
374

375
        fallback_icon = "map-marker"
1✔
376
        fallback_color = "#1E88E5"
1✔
377
        if a.get("Type") == BIKE_RACK_TYPE_NAME:
1✔
378
            fallback_icon = "bicycle"
1✔
379
            fallback_color = "#FF9800"
1✔
380

381
        final_amenities_list.append(
1✔
382
            {
383
                "id": a.get("Id"),
384
                "name": a.get("Name"),
385
                "latitude": float(a.get("Latitude", 0)),
386
                "longitude": float(a.get("Longitude", 0)),
387
                "address": a.get("Address", ""),
388
                "prop_name": a.get("Name", ""),
389
                "description": a.get("Description", ""),
390
                "operator": a.get("Operator", ""),
391
                "hours_of_operation": a.get("HoursOfOperation", {}),
392
                "changing_stations": a.get("ChangingStations", False),
393
                "accessibility": a.get("Accessibility", ""),
394
                "rating": float(a.get("AverageRating", 0)),
395
                "review_count": int(a.get("ReviewCount", 0)),
396
                "reviews": [],  # Dynamodb denormalized attribute goes here
397
                "photo_url": a.get("PrimaryPhotoUrl", None),
398
                "active": a.get("Active", True),
399
                "type": a.get("Type", ""),
400
                "type_id": amenity_type_obj.id if amenity_type_obj else None,
401
                "icon": amenity_type_obj.icon if amenity_type_obj else fallback_icon,
402
                "color": amenity_type_obj.color if amenity_type_obj else fallback_color,
403
                "is_favorited": False,
404
            }
405
        )
406

407
    return JsonResponse({"amenities": final_amenities_list})
1✔
408

409

410
@require_http_methods(["GET"])
1✔
411
def amenity_detail_api(request, amenity_id):
1✔
412
    dynamodb = get_dynamodb_resource()
1✔
413
    table = dynamodb.Table(settings.DYNAMODB_TABLE_NAME)
1✔
414

415
    try:
1✔
416
        response = table.query(
1✔
417
            KeyConditionExpression=Key("PK").eq(f"AMENITY#{amenity_id}")
418
        )
419
        items = response.get("Items", [])
1✔
420

421
        if not items:
1✔
422
            return JsonResponse({"error": "Amenity not found"}, status=404)
1✔
423

424
        amenity = next(
1✔
425
            (item for item in items if item["SK"] == f"AMENITY#{amenity_id}"), None
426
        )
427
        if not amenity:
1✔
NEW
428
            return JsonResponse({"error": "Amenity not found"}, status=404)
×
429

430
        try:
1✔
431
            sqlite_amenity = Amenity.objects.get(external_id=amenity_id)
1✔
NEW
432
        except Amenity.DoesNotExist:
×
NEW
433
            try:
×
NEW
434
                sqlite_amenity = Amenity.objects.get(id=amenity_id)
×
NEW
435
            except (Amenity.DoesNotExist, ValueError):
×
NEW
436
                sqlite_amenity = None
×
437

438
        is_fav = False
1✔
439
        serialized_reviews = []
1✔
440
        avg_rating = 0.0
1✔
441
        review_count = 0
1✔
442

443
        if sqlite_amenity:
1✔
444
            if request.user.is_authenticated:
1✔
445
                is_fav = Favorite.objects.filter(
1✔
446
                    user=request.user, amenity=sqlite_amenity
447
                ).exists()
448

449
            avg_rating = sqlite_amenity.get_average_rating() or 0.0
1✔
450
            review_count = sqlite_amenity.get_review_count()
1✔
451

452
            reviews_qs = get_review_prefetch_queryset(request.user).filter(
1✔
453
                amenity=sqlite_amenity
454
            )[:5]
455
            for review in reviews_qs:
1✔
456
                photo_urls = [p.photo.url for p in review.photos.all()]
1✔
457
                serialized_reviews.append(
1✔
458
                    serialize_amenity_review(
459
                        review,
460
                        photo_url=photo_urls[0] if photo_urls else None,
461
                        photo_urls=photo_urls,
462
                        current_user=request.user,
463
                    )
464
                )
465

466
        amenity_data = {
1✔
467
            "id": amenity.get("Id"),
468
            "name": amenity.get("Name"),
469
            "latitude": float(amenity.get("Latitude", 0)),
470
            "longitude": float(amenity.get("Longitude", 0)),
471
            "address": amenity.get("Address", ""),
472
            "prop_name": amenity.get("Name", ""),
473
            "description": amenity.get("Description", ""),
474
            "operator": amenity.get("Operator", ""),
475
            "hours_of_operation": amenity.get("HoursOfOperation", {}),
476
            "changing_stations": amenity.get("ChangingStations", False),
477
            "accessibility": amenity.get("Accessibility", ""),
478
            "rating": float(avg_rating) if avg_rating else None,
479
            "review_count": int(review_count),
480
            "reviews": serialized_reviews,
481
            "photo_url": amenity.get("PrimaryPhotoUrl", None),
482
            "active": amenity.get("Active", True),
483
            "type": amenity.get("Type", ""),
484
            "type_id": None,
485
            "icon": "map-marker",
486
            "color": "#1E88E5",
487
            "is_favorited": is_fav,
488
        }
489

490
        try:
1✔
491
            amenity_type = AmenityType.objects.get(name=amenity.get("Type", ""))
1✔
492
            amenity_data["icon"] = amenity_type.icon
1✔
493
            amenity_data["color"] = amenity_type.color
1✔
494
            amenity_data["type_id"] = amenity_type.id
1✔
NEW
495
        except AmenityType.DoesNotExist:
×
NEW
496
            if amenity.get("Type") == "Bike Rack":
×
NEW
497
                amenity_data["icon"] = "bicycle"
×
NEW
498
                amenity_data["color"] = "#FF9800"
×
499

500
        return JsonResponse({"amenity": amenity_data}, status=200)
1✔
501

NEW
502
    except Exception as e:
×
NEW
503
        return JsonResponse({"error": str(e)}, status=500)
×
504

505

506
@cache_control(public=True, max_age=86400)
1✔
507
def amenity_types_api(request):
1✔
508
    """API endpoint to fetch all amenity types."""
509
    # Fetch only top-level types (those without a parent)
510
    top_level_types = AmenityType.objects.filter(parent__isnull=True).prefetch_related(
1✔
511
        "sub_types"
512
    )
513

514
    data = {
1✔
515
        "types": [
516
            {
517
                "id": t.id,
518
                "name": t.name,
519
                "color": t.color,
520
                "icon": t.icon,
521
                "sub_types": [
522
                    {
523
                        "id": st.id,
524
                        "name": st.name,
525
                        "color": st.color,
526
                        "icon": st.icon,
527
                    }
528
                    for st in t.sub_types.all()
529
                ],
530
            }
531
            for t in top_level_types
532
        ]
533
    }
534
    return JsonResponse(data)
1✔
535

536

537
@csrf_exempt
1✔
538
@require_http_methods(["POST"])
1✔
539
def register_api(request):
1✔
540
    """API endpoint for user registration."""
541
    try:
1✔
542
        data = json.loads(request.body)
1✔
543
        email = data.get("email", "").strip()
1✔
544
        password = data.get("password") or ""
1✔
545
        confirm_password = data.get("confirm_password") or ""
1✔
546

547
        if not email or not password or not confirm_password:
1✔
548
            return JsonResponse(
1✔
549
                {"error": "Email, password, and confirmation required"},
550
                status=400,
551
            )
552

553
        if password != confirm_password:
1✔
554
            return JsonResponse(
1✔
555
                {"error": "Password and confirmation do not match"},
556
                status=400,
557
            )
558

559
        if CustomUser.objects.filter(email=email).exists():
1✔
560
            return JsonResponse({"error": "Email already registered"}, status=400)
1✔
561

562
        pending_user = CustomUser(username=email, email=email)
1✔
563
        try:
1✔
564
            validate_password(password, pending_user)
1✔
565
        except ValidationError as exc:
1✔
566
            return JsonResponse({"error": " ".join(exc.messages)}, status=400)
1✔
567

568
        # Create user with email as username and custom fields
569
        user = CustomUser.objects.create_user(
1✔
570
            username=email, email=email, password=password
571
        )
572

573
        return JsonResponse(
1✔
574
            {
575
                "id": user.id,
576
                "email": user.email,
577
                "message": "User registered successfully",
578
            },
579
            status=201,
580
        )
581

582
    except json.JSONDecodeError:
1✔
583
        return JsonResponse({"error": "Invalid JSON"}, status=400)
1✔
584
    except Exception as e:
×
585
        return JsonResponse({"error": str(e)}, status=500)
×
586

587

588
def serialize_auth_user(user):
1✔
589
    return {
1✔
590
        "id": user.id,
591
        "email": user.email,
592
        "username": user.username,
593
        "bio": user.bio,
594
        "avatar_url": user.avatar_url,
595
        "has_usable_password": user.has_usable_password(),
596
        "is_authenticated": True,
597
    }
598

599

600
def serialize_amenity_review(
1✔
601
    review, photo_url=None, photo_urls=None, current_user=None
602
):
603
    current_user_vote = getattr(review, "user_vote", 0)
1✔
604
    if not (current_user and current_user.is_authenticated):
1✔
605
        current_user_vote = 0
1✔
606

607
    return {
1✔
608
        "id": review.id,
609
        "amenity_id": review.amenity.external_id or review.amenity_id,
610
        "user_name": review.user.username or review.user.email,
611
        "user_email": review.user.email,
612
        "user_avatar_url": review.user.avatar_url,
613
        "rating": review.rating,
614
        "vote_score": int(getattr(review, "vote_score", 0)),
615
        "upvote_count": int(getattr(review, "upvote_count", 0)),
616
        "downvote_count": int(getattr(review, "downvote_count", 0)),
617
        "user_vote": int(current_user_vote or 0),
618
        "review_text": review.review_text,
619
        "photo_url": photo_url,
620
        "photo_urls": photo_urls or ([photo_url] if photo_url else []),
621
        "created_at": review.created_at.isoformat(),
622
    }
623

624

625
@csrf_exempt
1✔
626
@require_http_methods(["POST"])
1✔
627
def login_api(request):
1✔
628
    """
629
    API endpoint for user login.
630
    """
631
    try:
1✔
632
        data = json.loads(request.body)
1✔
633
        email = data.get("email", "").strip()
1✔
634
        password = data.get("password", "").strip()
1✔
635

636
        if not email or not password:
1✔
637
            return JsonResponse({"error": "Email and password required"}, status=400)
×
638

639
        # Since username field is email, authenticate with email
640
        user = authenticate(request, username=email, password=password)
1✔
641

642
        if user is None:
1✔
643
            return JsonResponse({"error": "Invalid email or password"}, status=401)
1✔
644

645
        # create the session
646
        login(request, user)
1✔
647

648
        response_data = serialize_auth_user(user)
1✔
649
        response_data["message"] = "Login successful"
1✔
650
        return JsonResponse(response_data, status=200)
1✔
651

652
    except json.JSONDecodeError:
1✔
653
        return JsonResponse({"error": "Invalid JSON"}, status=400)
1✔
654
    except Exception as e:
×
655
        return JsonResponse({"error": str(e)}, status=500)
×
656

657

658
@csrf_exempt
1✔
659
@require_http_methods(["POST"])
1✔
660
def logout_api(request):
1✔
661
    """
662
    Logout endpoint: clear the current session.
663
    """
664
    logout(request)
1✔
665
    return JsonResponse(
1✔
666
        {
667
            "message": "Logout successful",
668
            "is_authenticated": False,
669
        },
670
        status=200,
671
    )
672

673

674
@require_http_methods(["GET"])
1✔
675
def current_user_api(request):
1✔
676
    """
677
    Return the user tied to the current session.
678
    The frontend uses this endpoint after refresh to restore auth state.
679
    """
680
    if not request.user.is_authenticated:
1✔
681
        return JsonResponse(
1✔
682
            {
683
                "id": None,
684
                "email": "",
685
                "username": "",
686
                "bio": "",
687
                "has_usable_password": False,
688
                "is_authenticated": False,
689
            },
690
            status=200,
691
        )
692

693
    return JsonResponse(serialize_auth_user(request.user), status=200)
1✔
694

695

696
def get_profile_target_user(request):
1✔
697
    user_email = request.GET.get("user")
1✔
698
    if user_email:
1✔
699
        try:
1✔
700
            return CustomUser.objects.get(email=user_email)
1✔
701
        except CustomUser.DoesNotExist:
×
702
            return request.user
×
703
    return request.user
1✔
704

705

706
@login_required(login_url="/?auth_required=1")
1✔
707
def profile_view(request):
1✔
708
    """
709
    Profile page.
710
    Anonymous users are redirected back to the map page.
711
    If a ?user=<email> query param is provided, show that user's profile.
712
    """
713
    profile_user = get_profile_target_user(request)
1✔
714
    return render(
1✔
715
        request,
716
        "maps/profile.html",
717
        {
718
            "profile_user": profile_user,
719
            "reviews_count": profile_user.reviews.count(),
720
            "likes_received_count": ReviewVote.objects.filter(
721
                review__user=profile_user,
722
                value=1,
723
            ).count(),
724
        },
725
    )
726

727

728
@login_required(login_url="/?auth_required=1")
1✔
729
def settings_view(request):
1✔
730
    """
731
    Render the settings page for the current user.
732
    """
733
    return render(
1✔
734
        request,
735
        "maps/settings.html",
736
        {
737
            "profile_user": request.user,
738
            "has_usable_password": request.user.has_usable_password(),
739
        },
740
    )
741

742

743
@csrf_exempt
1✔
744
@login_required(login_url="/?auth_required=1")
1✔
745
@require_http_methods(["POST"])
1✔
746
def change_password_api(request):
1✔
747
    """
748
    Change the current user's password while preserving the active session.
749
    """
750
    has_usable_password = request.user.has_usable_password()
1✔
751
    current_password = (request.POST.get("current_password") or "").strip()
1✔
752
    new_password = (request.POST.get("new_password") or "").strip()
1✔
753
    confirm_password = (request.POST.get("confirm_password") or "").strip()
1✔
754

755
    if has_usable_password:
1✔
756
        if not current_password or not new_password or not confirm_password:
1✔
757
            return JsonResponse(
1✔
758
                {"error": "All password fields are required"},
759
                status=400,
760
            )
761
    elif not new_password or not confirm_password:
1✔
762
        return JsonResponse(
1✔
763
            {"error": "New password and confirmation are required"},
764
            status=400,
765
        )
766

767
    if has_usable_password and not request.user.check_password(current_password):
1✔
768
        return JsonResponse({"error": "Current password is incorrect"}, status=400)
1✔
769

770
    if new_password != confirm_password:
1✔
771
        return JsonResponse(
1✔
772
            {"error": "New password and confirmation do not match"},
773
            status=400,
774
        )
775

776
    if has_usable_password and current_password == new_password:
1✔
777
        return JsonResponse(
1✔
778
            {"error": "New password must be different from your current password"},
779
            status=400,
780
        )
781

782
    try:
1✔
783
        validate_password(new_password, request.user)
1✔
784
    except ValidationError as exc:
1✔
785
        return JsonResponse({"error": " ".join(exc.messages)}, status=400)
1✔
786

787
    request.user.set_password(new_password)
1✔
788
    request.user.save(update_fields=["password"])
1✔
789
    update_session_auth_hash(request, request.user)
1✔
790

791
    return JsonResponse(
1✔
792
        {
793
            "message": (
794
                "Password updated successfully"
795
                if has_usable_password
796
                else "Password set successfully"
797
            ),
798
            "has_usable_password": True,
799
            "password_action": "change" if has_usable_password else "set",
800
        },
801
        status=200,
802
    )
803

804

805
@csrf_exempt
1✔
806
@login_required(login_url="/?auth_required=1")
1✔
807
@require_http_methods(["POST"])
1✔
808
def update_profile_api(request):
1✔
809
    """
810
    Update the current user's profile fields from the dedicated edit page.
811
    """
812
    user = request.user
1✔
813

814
    username = (request.POST.get("username") or "").strip()
1✔
815
    bio = (request.POST.get("bio") or "").strip()
1✔
816
    avatar_file = request.FILES.get("avatar")
1✔
817

818
    if not username:
1✔
819
        return JsonResponse({"error": "Username is required"}, status=400)
1✔
820

821
    if len(username) > 30:
1✔
822
        return JsonResponse(
1✔
823
            {"error": "Username must be 30 characters or fewer"},
824
            status=400,
825
        )
826

827
    if CustomUser.objects.filter(username=username).exclude(id=user.id).exists():
1✔
828
        return JsonResponse({"error": "Username is already taken"}, status=400)
1✔
829

830
    if len(bio) > 150:
1✔
831
        return JsonResponse(
1✔
832
            {"error": "Bio must be 150 characters or fewer"},
833
            status=400,
834
        )
835

836
    if avatar_file:
1✔
837
        content_type = avatar_file.content_type or ""
1✔
838
        if not content_type.startswith("image/"):
1✔
839
            return JsonResponse({"error": "Avatar must be an image"}, status=400)
1✔
840

841
        # Automatically compress avatars to a maximum of 512x512
842
        avatar_file = compress_image(avatar_file, max_dimension=512)
1✔
843

844
        if avatar_file.size > 2 * 1024 * 1024:
1✔
845
            return JsonResponse(
1✔
846
                {"error": "Avatar must be 2MB or smaller"},
847
                status=400,
848
            )
849

850
        user.avatar = avatar_file
1✔
851

852
    user.username = username
1✔
853
    user.bio = bio
1✔
854
    user.save()
1✔
855

856
    response_data = serialize_auth_user(user)
1✔
857
    response_data["message"] = "Profile updated successfully"
1✔
858
    return JsonResponse(response_data, status=200)
1✔
859

860

861
def serialize_profile_review(review):
1✔
862
    """
863
    Serialize one review for the profile page.
864
    The photo is inferred from the amenity photo uploaded by the same user.
865
    """
866
    review_photos = []
1✔
867

868
    # Reuse prefetched amenity photos when available.
869
    # The current data model stores review photos on AmenityPhoto,
870
    # not directly on Review.
871
    for photo in review.amenity.photos.all():
1✔
872
        if photo.uploaded_by_id == review.user_id:
1✔
873
            review_photos.append(photo)
1✔
874

875
    review_photo_ids = []
1✔
876
    review_photo_urls = []
1✔
877
    for photo in review_photos:
1✔
878
        # Some legacy or malformed AmenityPhoto rows may not have a file
879
        # attached. Skip those rows so one bad photo does not break profile.
880
        try:
1✔
881
            photo_url = photo.photo.url
1✔
882
        except ValueError:
1✔
883
            continue
1✔
884

885
        review_photo_ids.append(photo.id)
1✔
886
        review_photo_urls.append(photo_url)
1✔
887

888
    return {
1✔
889
        "id": review.id,
890
        "amenity_id": review.amenity.external_id or review.amenity_id,
891
        "amenity_name": review.amenity.name,
892
        "amenity_prop_name": review.amenity.prop_name,
893
        "amenity_type": review.amenity.amenity_type.name,
894
        "rating": review.rating,
895
        "vote_score": int(getattr(review, "vote_score", 0)),
896
        "upvote_count": int(getattr(review, "upvote_count", 0)),
897
        "downvote_count": int(getattr(review, "downvote_count", 0)),
898
        "user_vote": int(getattr(review, "user_vote", 0) or 0),
899
        "review_text": review.review_text,
900
        "photo_id": review_photo_ids[0] if review_photo_ids else None,
901
        "photo_url": review_photo_urls[0] if review_photo_urls else None,
902
        "photo_ids": review_photo_ids,
903
        "photo_urls": review_photo_urls,
904
        "created_at": review.created_at.isoformat(),
905
        "updated_at": review.updated_at.isoformat(),
906
    }
907

908

909
def annotate_reviews_with_vote_score(queryset, user=None):
1✔
910
    queryset = queryset.annotate(
1✔
911
        vote_score=Coalesce(Sum("votes__value"), Value(0), output_field=IntegerField()),
912
        upvote_count=Count("votes", filter=Q(votes__value=1)),
913
        downvote_count=Count("votes", filter=Q(votes__value=-1)),
914
    )
915
    if user and user.is_authenticated:
1✔
916
        queryset = queryset.annotate(
1✔
917
            user_vote=Coalesce(
918
                Sum(
919
                    "votes__value",
920
                    filter=Q(votes__user=user),
921
                ),
922
                Value(0),
923
                output_field=IntegerField(),
924
            )
925
        )
926
    return queryset
1✔
927

928

929
def parse_multipart_request(request):
1✔
930
    parser = MultiPartParser(
1✔
931
        request.META,
932
        request,
933
        request.upload_handlers,
934
        encoding=request.encoding,
935
    )
936
    return parser.parse()
1✔
937

938

939
def serialize_profile_favorite(favorite):
1✔
940
    amenity = favorite.amenity
1✔
941
    return {
1✔
942
        "id": favorite.id,
943
        "amenity_id": amenity.external_id or amenity.id,
944
        "amenity_name": amenity.name,
945
        "amenity_prop_name": amenity.prop_name,
946
        "amenity_type": amenity.amenity_type.name,
947
        "address": amenity.address,
948
        "latitude": amenity.latitude,
949
        "longitude": amenity.longitude,
950
        "notify_on_updates": favorite.notify_on_updates,
951
        "created_at": favorite.created_at.isoformat(),
952
    }
953

954

955
@login_required(login_url="/?auth_required=1")
1✔
956
@require_http_methods(["GET"])
1✔
957
def profile_reviews_api(request):
1✔
958
    """
959
    Return all reviews written by the profile target user.
960
    """
961
    profile_user = get_profile_target_user(request)
1✔
962
    reviews = (
1✔
963
        annotate_reviews_with_vote_score(
964
            Review.objects.filter(user=profile_user),
965
            request.user,
966
        )
967
        .select_related("amenity", "amenity__amenity_type")
968
        .prefetch_related("amenity__photos")
969
        .order_by("-updated_at", "-created_at")
970
    )
971

972
    return JsonResponse(
1✔
973
        {
974
            "reviews": [serialize_profile_review(review) for review in reviews],
975
        },
976
        status=200,
977
    )
978

979

980
@login_required(login_url="/?auth_required=1")
1✔
981
@require_http_methods(["GET"])
1✔
982
def profile_favorites_api(request):
1✔
983
    profile_user = get_profile_target_user(request)
1✔
984
    favorites = (
1✔
985
        Favorite.objects.filter(user=profile_user)
986
        .select_related("amenity", "amenity__amenity_type")
987
        .order_by("-created_at")
988
    )
989

990
    return JsonResponse(
1✔
991
        {
992
            "favorites": [
993
                serialize_profile_favorite(favorite) for favorite in favorites
994
            ],
995
        },
996
        status=200,
997
    )
998

999

1000
@csrf_exempt
1✔
1001
@login_required(login_url="/?auth_required=1")
1✔
1002
@require_http_methods(["POST"])
1✔
1003
def favorite_notification_preference_api(request, favorite_id):
1✔
1004
    try:
1✔
1005
        favorite = Favorite.objects.get(id=favorite_id, user=request.user)
1✔
1006
    except Favorite.DoesNotExist:
×
1007
        return JsonResponse({"error": "Favorite not found"}, status=404)
×
1008

1009
    try:
1✔
1010
        data = json.loads(request.body)
1✔
1011
    except json.JSONDecodeError:
×
1012
        return JsonResponse({"error": "Invalid JSON"}, status=400)
×
1013

1014
    notify_on_updates = data.get("notify_on_updates")
1✔
1015
    if not isinstance(notify_on_updates, bool):
1✔
1016
        return JsonResponse(
1✔
1017
            {"error": "notify_on_updates must be a boolean"},
1018
            status=400,
1019
        )
1020

1021
    favorite.notify_on_updates = notify_on_updates
1✔
1022
    favorite.save(update_fields=["notify_on_updates"])
1✔
1023

1024
    return JsonResponse(
1✔
1025
        {
1026
            "favorite_id": favorite.id,
1027
            "notify_on_updates": favorite.notify_on_updates,
1028
            "message": "Favorite notification preference updated",
1029
        },
1030
        status=200,
1031
    )
1032

1033

1034
@csrf_exempt
1✔
1035
@login_required(login_url="/?auth_required=1")
1✔
1036
@require_http_methods(["POST", "DELETE"])
1✔
1037
def toggle_favorite_api(request, amenity_id):
1✔
1038
    try:
1✔
1039
        amenity = Amenity.objects.get(external_id=amenity_id)
1✔
1040
    except Amenity.DoesNotExist:
1✔
1041
        try:
1✔
1042
            amenity = Amenity.objects.get(id=amenity_id)
1✔
NEW
1043
        except (Amenity.DoesNotExist, ValueError):
×
NEW
1044
            return JsonResponse({"error": "Amenity not found"}, status=404)
×
1045

1046
    if request.method == "POST":
1✔
1047
        favorite, created = Favorite.objects.get_or_create(
1✔
1048
            user=request.user,
1049
            amenity=amenity,
1050
        )
1051
        return JsonResponse(
1✔
1052
            {
1053
                "amenity_id": amenity.external_id or amenity.id,
1054
                "is_favorited": True,
1055
                "notify_on_updates": favorite.notify_on_updates,
1056
                "message": "Added to favorites" if created else "Already favorited",
1057
            },
1058
            status=200,
1059
        )
1060

1061
    Favorite.objects.filter(user=request.user, amenity=amenity).delete()
1✔
1062
    return JsonResponse(
1✔
1063
        {
1064
            "amenity_id": amenity.external_id or amenity.id,
1065
            "is_favorited": False,
1066
            "message": "Removed from favorites",
1067
        },
1068
        status=200,
1069
    )
1070

1071

1072
@cache_control(public=True, max_age=300)
1✔
1073
@require_http_methods(["GET"])
1✔
1074
def amenity_rating_distribution_api(request, amenity_id):
1✔
1075
    """API endpoint to fetch the rating distribution for a specific amenity."""
1076
    try:
×
NEW
1077
        amenity = Amenity.objects.get(external_id=amenity_id)
×
1078
    except Amenity.DoesNotExist:
×
NEW
1079
        try:
×
NEW
1080
            amenity = Amenity.objects.get(id=amenity_id)
×
NEW
1081
        except (Amenity.DoesNotExist, ValueError):
×
NEW
1082
            return JsonResponse({"error": "Amenity not found"}, status=404)
×
1083

1084
    rating_distribution = list(
×
1085
        Review.objects.filter(amenity=amenity)
1086
        .values("rating")
1087
        .annotate(count=Count("rating"))
1088
        .order_by("-rating")
1089
    )
1090

1091
    return JsonResponse(
×
1092
        {"amenity_id": amenity_id, "rating_distribution": rating_distribution},
1093
        status=200,
1094
    )
1095

1096

1097
@csrf_exempt
1✔
1098
@require_http_methods(["POST"])
1✔
1099
def create_review_api(request):
1✔
1100
    """API endpoint for submitting reviews."""
1101
    try:
1✔
1102
        if not request.user.is_authenticated:
1✔
1103
            return JsonResponse({"error": "Login required"}, status=401)
1✔
1104

1105
        content_type = request.META.get("CONTENT_TYPE", "")
1✔
1106
        if content_type.startswith("multipart/form-data"):
1✔
1107
            amenity_id = request.POST.get("amenity_id")
1✔
1108
            rating = request.POST.get("rating", 5)
1✔
1109
            review_text = (request.POST.get("review_text") or "").strip()
1✔
1110
            photo_files = request.FILES.getlist("photos")
1✔
1111
            if not photo_files and "photo" in request.FILES:
1✔
1112
                photo_files = request.FILES.getlist("photo")
×
1113
        else:
1114
            data = json.loads(request.body)
1✔
1115
            amenity_id = data.get("amenity_id")
1✔
1116
            rating = data.get("rating", 5)
1✔
1117
            review_text = (data.get("review_text") or "").strip()
1✔
1118
            photo_files = []
1✔
1119

1120
        if not amenity_id:
1✔
1121
            return JsonResponse({"error": "amenity_id required"}, status=400)
1✔
1122

1123
        try:
1✔
1124
            rating = int(rating)
1✔
1125
        except (TypeError, ValueError):
×
1126
            return JsonResponse({"error": "Rating must be an integer"}, status=400)
×
1127

1128
        if not (1 <= rating <= 5):
1✔
1129
            return JsonResponse({"error": "Rating must be between 1 and 5"}, status=400)
1✔
1130

1131
        if len(photo_files) > 5:
1✔
1132
            return JsonResponse({"error": "Maximum of 5 photos allowed"}, status=400)
1✔
1133

1134
        processed_photos = []
1✔
1135
        for photo_file in photo_files:
1✔
1136
            file_content_type = photo_file.content_type or ""
1✔
1137
            if not file_content_type.startswith("image/"):
1✔
1138
                return JsonResponse({"error": "Photo must be an image"}, status=400)
1✔
1139

1140
            # Compress review photos to 1024x1024 maximum
1141
            photo_file = compress_image(photo_file, max_dimension=1024)
1✔
1142

1143
            if photo_file.size > 5 * 1024 * 1024:
1✔
1144
                return JsonResponse(
×
1145
                    {"error": "Photo must be 5MB or smaller"}, status=400
1146
                )
1147
            processed_photos.append(photo_file)
1✔
1148

1149
        photo_files = processed_photos
1✔
1150

1151
        try:
1✔
1152
            amenity = Amenity.objects.get(external_id=amenity_id)
1✔
1153
        except Amenity.DoesNotExist:
1✔
1154
            try:
1✔
1155
                amenity = Amenity.objects.get(id=amenity_id)
1✔
1156
            except (Amenity.DoesNotExist, ValueError):
1✔
1157
                return JsonResponse({"error": "Amenity not found"}, status=404)
1✔
1158

1159
        user = request.user
1✔
1160

1161
        # Check if user already has a review for this amenity
1162
        if Review.objects.filter(amenity=amenity, user=user).exists():
1✔
1163
            return JsonResponse(
1✔
1164
                {"error": "You have already reviewed this amenity"}, status=400
1165
            )
1166

1167
        review = Review.objects.create(
1✔
1168
            amenity=amenity, user=user, rating=rating, review_text=review_text
1169
        )
1170

1171
        review_photos = []
1✔
1172
        is_first = not AmenityPhoto.objects.filter(amenity=amenity).exists()
1✔
1173
        for i, photo_file in enumerate(photo_files):
1✔
1174
            review_photo = AmenityPhoto.objects.create(
1✔
1175
                amenity=amenity,
1176
                review=review,
1177
                photo=photo_file,
1178
                uploaded_by=user,
1179
                is_primary=(is_first and i == 0),
1180
                caption=f"Review photo by {user.email}",
1181
            )
1182
            review_photos.append(review_photo)
1✔
1183

1184
        # Notify users who favorited this amenity and opted in for updates.
1185
        recipient_ids = list(
1✔
1186
            Favorite.objects.filter(
1187
                amenity=amenity,
1188
                notify_on_updates=True,
1189
            )
1190
            .exclude(user=user)
1191
            .values_list("user_id", flat=True)
1192
            .distinct()
1193
        )
1194

1195
        if recipient_ids:
1✔
1196
            payload = json.dumps(
×
1197
                {
1198
                    "type": "amenity_review_added",
1199
                    "amenity_id": amenity.external_id or amenity.id,
1200
                    "amenity_name": amenity.name,
1201
                    "review": {
1202
                        "id": review.id,
1203
                        "rating": review.rating,
1204
                        "created_at": review.created_at.isoformat(),
1205
                    },
1206
                    "actor_email": user.email,
1207
                }
1208
            )
1209

1210
            def send_notification():
×
NEW
1211
                for recipient_id in recipient_ids:
×
NEW
1212
                    try:
×
NEW
1213
                        requests.post(
×
1214
                            "http://127.0.0.1:8001/api/internal/publish/",
1215
                            json={"user_id": recipient_id, "payload": payload},
1216
                            timeout=1,
1217
                        )
NEW
1218
                    except Exception:
×
NEW
1219
                        pass
×
1220

1221
            transaction.on_commit(send_notification)
×
1222

1223
        response_data = serialize_amenity_review(
1✔
1224
            review,
1225
            photo_url=review_photos[0].photo.url if review_photos else None,
1226
            photo_urls=[rp.photo.url for rp in review_photos] if review_photos else [],
1227
            current_user=request.user,
1228
        )
1229
        response_data["message"] = "Review created successfully"
1✔
1230
        return JsonResponse(response_data, status=201)
1✔
1231

1232
    except json.JSONDecodeError:
1✔
1233
        return JsonResponse({"error": "Invalid JSON"}, status=400)
1✔
1234
    except Exception as e:
×
1235
        return JsonResponse({"error": str(e)}, status=500)
×
1236

1237

1238
@csrf_exempt
1✔
1239
@login_required(login_url="/?auth_required=1")
1✔
1240
@require_http_methods(["POST"])
1✔
1241
def review_vote_api(request, review_id):
1✔
1242
    """Create, update, or clear the current user's vote on a review."""
1243
    try:
1✔
1244
        try:
1✔
1245
            review = Review.objects.select_related("user").get(id=review_id)
1✔
1246
        except Review.DoesNotExist:
1✔
1247
            return JsonResponse({"error": "Review not found"}, status=404)
1✔
1248

1249
        if review.user_id == request.user.id:
1✔
1250
            return JsonResponse(
1✔
1251
                {"error": "You cannot vote on your own review"},
1252
                status=400,
1253
            )
1254

1255
        data = json.loads(request.body)
1✔
1256
        vote = data.get("vote")
1✔
1257

1258
        vote_value = None
1✔
1259
        if vote in (1, "1", "up", "upvote"):
1✔
1260
            vote_value = 1
1✔
1261
        elif vote in (-1, "-1", "down", "downvote"):
1✔
1262
            vote_value = -1
1✔
1263
        elif vote in (0, "0", None, "clear"):
1✔
1264
            vote_value = 0
1✔
1265

1266
        if vote_value is None:
1✔
1267
            return JsonResponse(
1✔
1268
                {"error": "vote must be one of: up, down, or clear"},
1269
                status=400,
1270
            )
1271

1272
        existing_vote = ReviewVote.objects.filter(
1✔
1273
            review=review,
1274
            user=request.user,
1275
        ).first()
1276

1277
        if vote_value == 0:
1✔
1278
            if existing_vote:
1✔
1279
                existing_vote.delete()
1✔
1280
            user_vote = 0
1✔
1281
        elif existing_vote and existing_vote.value == vote_value:
1✔
1282
            existing_vote.delete()
1✔
1283
            user_vote = 0
1✔
1284
        elif existing_vote:
1✔
1285
            existing_vote.value = vote_value
1✔
1286
            existing_vote.save(update_fields=["value", "updated_at"])
1✔
1287
            user_vote = vote_value
1✔
1288
        else:
1289
            ReviewVote.objects.create(
1✔
1290
                review=review,
1291
                user=request.user,
1292
                value=vote_value,
1293
            )
1294
            user_vote = vote_value
1✔
1295

1296
        vote_totals = ReviewVote.objects.filter(review=review).aggregate(
1✔
1297
            vote_score=Coalesce(Sum("value"), Value(0), output_field=IntegerField()),
1298
            upvote_count=Count("id", filter=Q(value=1)),
1299
            downvote_count=Count("id", filter=Q(value=-1)),
1300
        )
1301

1302
        return JsonResponse(
1✔
1303
            {
1304
                "review_id": review.id,
1305
                "vote_score": int(vote_totals["vote_score"] or 0),
1306
                "upvote_count": int(vote_totals["upvote_count"] or 0),
1307
                "downvote_count": int(vote_totals["downvote_count"] or 0),
1308
                "user_vote": int(user_vote),
1309
            },
1310
            status=200,
1311
        )
1312

1313
    except json.JSONDecodeError:
1✔
1314
        return JsonResponse({"error": "Invalid JSON"}, status=400)
1✔
1315
    except Exception as e:
×
1316
        return JsonResponse({"error": str(e)}, status=500)
×
1317

1318

1319
@csrf_exempt
1✔
1320
@login_required(login_url="/?auth_required=1")
1✔
1321
@require_http_methods(["PATCH", "DELETE"])
1✔
1322
def review_detail_api(request, review_id):
1✔
1323
    """
1324
    Update or delete the current user's own review from the profile page.
1325
    """
1326
    try:
1✔
1327
        try:
1✔
1328
            review = (
1✔
1329
                Review.objects.filter(id=review_id, user=request.user)
1330
                .select_related("amenity", "amenity__amenity_type")
1331
                .prefetch_related("amenity__photos")
1332
                .get()
1333
            )
1334
        except Review.DoesNotExist:
1✔
1335
            return JsonResponse({"error": "Review not found"}, status=404)
1✔
1336

1337
        if request.method == "DELETE":
1✔
1338
            review.delete()
1✔
1339
            return JsonResponse(
1✔
1340
                {"message": "Review deleted successfully"},
1341
                status=200,
1342
            )
1343

1344
        content_type = request.content_type or ""
1✔
1345
        if content_type.startswith("multipart/form-data"):
1✔
1346
            try:
1✔
1347
                data, files = parse_multipart_request(request)
1✔
1348
            except MultiPartParserError:
×
1349
                return JsonResponse({"error": "Invalid multipart payload"}, status=400)
×
1350

1351
            rating = data.get("rating", review.rating)
1✔
1352
            review_text = data.get("review_text", review.review_text)
1✔
1353
            photo_files = files.getlist("photos")
1✔
1354
            if not photo_files and "photo" in files:
1✔
1355
                photo_files = files.getlist("photo")
×
1356
        else:
1357
            data = json.loads(request.body)
1✔
1358
            rating = data.get("rating", review.rating)
1✔
1359
            review_text = data.get("review_text", review.review_text)
1✔
1360
            photo_files = []
1✔
1361

1362
        try:
1✔
1363
            rating = int(rating)
1✔
1364
        except (TypeError, ValueError):
×
1365
            return JsonResponse({"error": "Rating must be an integer"}, status=400)
×
1366

1367
        if not (1 <= rating <= 5):
1✔
1368
            return JsonResponse({"error": "Rating must be between 1 and 5"}, status=400)
1✔
1369

1370
        review_text = str(review_text or "").strip()
1✔
1371
        if len(review_text) > 600:
1✔
1372
            return JsonResponse(
1✔
1373
                {"error": "Review text must be 600 characters or fewer"},
1374
                status=400,
1375
            )
1376

1377
        current_photo_count = (
1✔
1378
            AmenityPhoto.objects.filter(
1379
                amenity_id=review.amenity_id,
1380
                uploaded_by=request.user,
1381
            )
1382
            .filter(Q(review=review) | Q(review__isnull=True))
1383
            .count()
1384
        )
1385

1386
        if current_photo_count + len(photo_files) > 5:
1✔
1387
            return JsonResponse(
×
1388
                {"error": "You can upload up to 5 photos per review."},
1389
                status=400,
1390
            )
1391

1392
        processed_photos = []
1✔
1393
        for photo_file in photo_files:
1✔
1394
            file_content_type = photo_file.content_type or ""
1✔
1395
            if not file_content_type.startswith("image/"):
1✔
1396
                return JsonResponse({"error": "Photo must be an image"}, status=400)
×
1397

1398
            photo_file = compress_image(photo_file, max_dimension=1024)
1✔
1399
            if photo_file.size > 5 * 1024 * 1024:
1✔
1400
                return JsonResponse(
×
1401
                    {"error": "Photo must be 5MB or smaller"}, status=400
1402
                )
1403
            processed_photos.append(photo_file)
1✔
1404

1405
        review.rating = rating
1✔
1406
        review.review_text = review_text
1✔
1407
        review.save(update_fields=["rating", "review_text", "updated_at"])
1✔
1408

1409
        is_first_amenity_photo = not AmenityPhoto.objects.filter(
1✔
1410
            amenity=review.amenity
1411
        ).exists()
1412
        for index, photo_file in enumerate(processed_photos):
1✔
1413
            AmenityPhoto.objects.create(
1✔
1414
                amenity=review.amenity,
1415
                review=review,
1416
                photo=photo_file,
1417
                uploaded_by=request.user,
1418
                is_primary=(is_first_amenity_photo and index == 0),
1419
                caption=f"Review photo by {request.user.email}",
1420
            )
1421

1422
        refreshed_review = (
1✔
1423
            annotate_reviews_with_vote_score(Review.objects.filter(id=review.id))
1424
            .select_related("amenity", "amenity__amenity_type")
1425
            .prefetch_related("amenity__photos")
1426
            .get()
1427
        )
1428

1429
        response_data = serialize_profile_review(refreshed_review)
1✔
1430
        response_data["message"] = "Review updated successfully"
1✔
1431
        return JsonResponse(response_data, status=200)
1✔
1432

1433
    except json.JSONDecodeError:
×
1434
        return JsonResponse({"error": "Invalid JSON"}, status=400)
×
1435
    except Exception as e:
×
1436
        return JsonResponse({"error": str(e)}, status=500)
×
1437

1438

1439
@csrf_exempt
1✔
1440
@login_required(login_url="/?auth_required=1")
1✔
1441
@require_http_methods(["DELETE"])
1✔
1442
def review_photo_detail_api(request, review_id, photo_id):
1✔
1443
    """
1444
    Delete one photo attached to the current user's own review.
1445
    """
1446
    try:
1✔
1447
        try:
1✔
1448
            review = (
1✔
1449
                Review.objects.filter(id=review_id, user=request.user)
1450
                .select_related("amenity", "amenity__amenity_type")
1451
                .prefetch_related("amenity__photos")
1452
                .get()
1453
            )
1454
        except Review.DoesNotExist:
×
1455
            return JsonResponse({"error": "Review not found"}, status=404)
×
1456

1457
        photo = (
1✔
1458
            AmenityPhoto.objects.filter(
1459
                id=photo_id,
1460
                amenity_id=review.amenity_id,
1461
                uploaded_by=request.user,
1462
            )
1463
            .filter(Q(review=review) | Q(review__isnull=True))
1464
            .first()
1465
        )
1466

1467
        if not photo:
1✔
1468
            return JsonResponse({"error": "Photo not found"}, status=404)
×
1469

1470
        photo.delete()
1✔
1471

1472
        refreshed_review = (
1✔
1473
            annotate_reviews_with_vote_score(Review.objects.filter(id=review.id))
1474
            .select_related("amenity", "amenity__amenity_type")
1475
            .prefetch_related("amenity__photos")
1476
            .get()
1477
        )
1478

1479
        response_data = serialize_profile_review(refreshed_review)
1✔
1480
        response_data["message"] = "Review photo deleted successfully"
1✔
1481
        return JsonResponse(response_data, status=200)
1✔
1482

1483
    except Exception as e:
×
1484
        return JsonResponse({"error": str(e)}, status=500)
×
1485

1486

1487
@require_http_methods(["GET"])
1✔
1488
def get_amenity_reviews_api(request):
1✔
1489
    """API endpoint to fetch all reviews for a specific amenity with pagination."""
1490
    try:
1✔
1491
        amenity_id = request.GET.get("amenity_id")
1✔
1492
        page = int(request.GET.get("page", 1))
1✔
1493
        page_size = int(request.GET.get("page_size", 10))
1✔
1494

1495
        if not amenity_id:
1✔
1496
            return JsonResponse({"error": "amenity_id parameter required"}, status=400)
1✔
1497

1498
        try:
1✔
1499
            sqlite_amenity = Amenity.objects.get(external_id=amenity_id)
1✔
1500
        except Amenity.DoesNotExist:
1✔
1501
            try:
1✔
1502
                sqlite_amenity = Amenity.objects.get(id=amenity_id)
1✔
1503
            except (Amenity.DoesNotExist, ValueError):
1✔
1504
                return JsonResponse({"error": "Amenity not found"}, status=404)
1✔
1505

1506
        reviews_qs = (
1✔
1507
            Review.objects.filter(amenity=sqlite_amenity)
1508
            .select_related("user")
1509
            .order_by("-created_at")
1510
        )
1511

1512
        total_count = reviews_qs.count()
1✔
1513
        start_idx = (page - 1) * page_size
1✔
1514
        end_idx = start_idx + page_size
1✔
1515
        paginated_reviews = reviews_qs[start_idx:end_idx]
1✔
1516

1517
        reviews_data = [
1✔
1518
            {
1519
                "id": r.id,
1520
                "user_name": r.user.username or r.user.email if r.user else "Anonymous",
1521
                "rating": r.rating,
1522
                "review_text": r.review_text,
1523
                "created_at": r.created_at.isoformat(),
1524
                "updated_at": r.updated_at.isoformat(),
1525
            }
1526
            for r in paginated_reviews
1527
        ]
1528

1529
        return JsonResponse(
1✔
1530
            {
1531
                "amenity_id": amenity_id,
1532
                "amenity_name": sqlite_amenity.name,
1533
                "total_reviews": total_count,
1534
                "average_rating": float(sqlite_amenity.get_average_rating() or 0),
1535
                "page": page,
1536
                "page_size": page_size,
1537
                "total_pages": (total_count + page_size - 1) // page_size,
1538
                "reviews": reviews_data,
1539
            },
1540
            status=200,
1541
        )
1542

1543
    except (ValueError, TypeError):
1✔
1544
        return JsonResponse(
1✔
1545
            {"error": "Invalid page or page_size parameter"}, status=400
1546
        )
1547
    except Exception as e:
×
1548
        return JsonResponse({"error": str(e)}, status=500)
×
1549

1550

1551
# ===== Chat Functionality APIs =====
1552

1553

1554
@csrf_exempt
1✔
1555
@require_http_methods(["GET"])
1✔
1556
def get_user_chats_api(request):
1✔
1557
    """Get all chats for the current user."""
1558
    try:
1✔
1559
        if not request.user.is_authenticated:
1✔
1560
            return JsonResponse({"error": "Login required"}, status=401)
1✔
1561

1562
        # Get all chats where the user is a participant
1563
        user_chats = (
1✔
1564
            Chat.objects.filter(participants__user=request.user)
1565
            .select_related("created_by", "amenity")
1566
            .prefetch_related("participants__user", "messages")
1567
            .distinct()
1568
            .order_by("-last_message_at")
1569
        )
1570

1571
        chats_data = []
1✔
1572
        for chat in user_chats:
1✔
1573
            # Get the last message (using prefetched list to avoid N+1 queries)
1574
            messages_list = list(chat.messages.all())
1✔
1575
            last_message = messages_list[-1] if messages_list else None
1✔
1576

1577
            my_participant = next(
1✔
1578
                (p for p in chat.participants.all() if p.user_id == request.user.id),
1579
                None,
1580
            )
1581

1582
            is_unread = False
1✔
1583
            if last_message and last_message.sender_id != request.user.id:
1✔
1584
                if my_participant and (
×
1585
                    not my_participant.last_read_at
1586
                    or last_message.created_at > my_participant.last_read_at
1587
                ):
1588
                    is_unread = True
×
1589

1590
            avatar_url = None
1✔
1591
            other_user_email = None
1✔
1592
            if chat.chat_type == "direct":
1✔
1593
                # Find the other participant to get their avatar and email
1594
                other_p = next(
1✔
1595
                    (
1596
                        p
1597
                        for p in chat.participants.all()
1598
                        if p.user_id != request.user.id
1599
                    ),
1600
                    None,
1601
                )
1602
                if other_p:
1✔
1603
                    if getattr(other_p.user, "avatar_url", None):
1✔
1604
                        avatar_url = other_p.user.avatar_url
1✔
1605
                    other_user_email = other_p.user.email
1✔
1606
            else:
1607
                if chat.created_by and getattr(chat.created_by, "avatar_url", None):
×
1608
                    avatar_url = chat.created_by.avatar_url
×
1609

1610
            chats_data.append(
1✔
1611
                {
1612
                    "id": chat.id,
1613
                    "chat_type": chat.chat_type,
1614
                    "name": chat.get_display_name(request.user),
1615
                    "avatar_url": avatar_url,
1616
                    "other_user_email": other_user_email,
1617
                    "amenity_id": (
1618
                        chat.amenity.external_id
1619
                        if (chat.amenity and chat.amenity.external_id)
1620
                        else (chat.amenity.id if chat.amenity else None)
1621
                    ),
1622
                    "amenity_name": chat.amenity.name if chat.amenity else None,
1623
                    "created_by_email": (
1624
                        chat.created_by.email if chat.created_by else None
1625
                    ),
1626
                    "participant_count": chat.participants.count(),
1627
                    "last_message": (
1628
                        last_message.content[:100]
1629
                        if last_message and last_message.content
1630
                        else None
1631
                    ),
1632
                    "last_message_sender": (
1633
                        last_message.sender.email if last_message else None
1634
                    ),
1635
                    "last_message_at": (
1636
                        chat.last_message_at.isoformat()
1637
                        if chat.last_message_at
1638
                        else (chat.created_at.isoformat() if chat.created_at else None)
1639
                    ),
1640
                    "created_at": (
1641
                        chat.created_at.isoformat() if chat.created_at else None
1642
                    ),
1643
                    "is_unread": is_unread,
1644
                }
1645
            )
1646

1647
        return JsonResponse(
1✔
1648
            {
1649
                "chats": chats_data,
1650
                "total_count": len(chats_data),
1651
            },
1652
            status=200,
1653
        )
1654

1655
    except Exception as e:
×
1656
        return JsonResponse({"error": str(e)}, status=500)
×
1657

1658

1659
@csrf_exempt
1✔
1660
@require_http_methods(["GET"])
1✔
1661
def get_chat_messages_api(request):
1✔
1662
    """Get messages for a specific chat."""
1663
    try:
1✔
1664
        if not request.user.is_authenticated:
1✔
1665
            return JsonResponse({"error": "Login required"}, status=401)
1✔
1666

1667
        chat_id = request.GET.get("chat_id")
1✔
1668
        page = int(request.GET.get("page", 1))
1✔
1669
        page_size = int(request.GET.get("page_size", 20))
1✔
1670

1671
        if not chat_id:
1✔
1672
            return JsonResponse({"error": "chat_id parameter required"}, status=400)
1✔
1673

1674
        try:
1✔
1675
            chat = Chat.objects.get(id=chat_id)
1✔
1676
        except Chat.DoesNotExist:
1✔
1677
            return JsonResponse({"error": "Chat not found"}, status=404)
1✔
1678

1679
        # Check if user is a participant in this chat
1680
        participant = chat.participants.filter(user=request.user).first()
1✔
1681
        if not participant:
1✔
1682
            return JsonResponse(
1✔
1683
                {"error": "You are not a participant in this chat"}, status=403
1684
            )
1685

1686
        participant.last_read_at = timezone.now()
1✔
1687
        participant.save(update_fields=["last_read_at"])
1✔
1688

1689
        # Get messages with pagination
1690
        messages = chat.messages.select_related("sender").order_by("-created_at", "-id")
1✔
1691
        total_count = messages.count()
1✔
1692
        start_idx = (page - 1) * page_size
1✔
1693
        end_idx = start_idx + page_size
1✔
1694
        paginated_messages = messages[start_idx:end_idx]
1✔
1695

1696
        messages_data = [
1✔
1697
            {
1698
                "id": m.id,
1699
                "sender_id": m.sender.id if m.sender else None,
1700
                "sender_email": m.sender.email if m.sender else None,
1701
                "content": m.content,
1702
                "created_at": m.created_at.isoformat() if m.created_at else None,
1703
            }
1704
            for m in paginated_messages
1705
        ]
1706

1707
        # Reverse to get chronological order
1708
        messages_data.reverse()
1✔
1709

1710
        return JsonResponse(
1✔
1711
            {
1712
                "chat_id": chat.id,
1713
                "chat_type": chat.chat_type,
1714
                "chat_name": chat.get_display_name(request.user),
1715
                "amenity_id": (
1716
                    chat.amenity.external_id
1717
                    if (chat.amenity and chat.amenity.external_id)
1718
                    else (chat.amenity.id if chat.amenity else None)
1719
                ),
1720
                "page": page,
1721
                "page_size": page_size,
1722
                "total_messages": total_count,
1723
                "total_pages": (total_count + page_size - 1) // page_size,
1724
                "messages": messages_data,
1725
            },
1726
            status=200,
1727
        )
1728

1729
    except (ValueError, TypeError):
1✔
1730
        return JsonResponse(
1✔
1731
            {"error": "Invalid page or page_size parameter"}, status=400
1732
        )
1733
    except Exception as e:
×
1734
        return JsonResponse({"error": str(e)}, status=500)
×
1735

1736

1737
@csrf_exempt
1✔
1738
@require_http_methods(["POST"])
1✔
1739
def send_message_api(request):
1✔
1740
    """Send a message in a chat."""
1741
    try:
1✔
1742
        if not request.user.is_authenticated:
1✔
1743
            return JsonResponse({"error": "Login required"}, status=401)
1✔
1744

1745
        data = json.loads(request.body)
1✔
1746
        chat_id = data.get("chat_id")
1✔
1747
        content = (data.get("content") or "").strip()
1✔
1748

1749
        if not chat_id:
1✔
1750
            return JsonResponse({"error": "chat_id required"}, status=400)
1✔
1751

1752
        if not content:
1✔
1753
            return JsonResponse({"error": "content required"}, status=400)
1✔
1754

1755
        try:
1✔
1756
            chat = Chat.objects.get(id=chat_id)
1✔
1757
        except Chat.DoesNotExist:
1✔
1758
            return JsonResponse({"error": "Chat not found"}, status=404)
1✔
1759

1760
        # Check if user is a participant
1761
        if not chat.participants.filter(user=request.user).exists():
1✔
1762
            return JsonResponse(
1✔
1763
                {"error": "You are not a participant in this chat"}, status=403
1764
            )
1765

1766
        # Create the message
1767
        message = Message.objects.create(
1✔
1768
            chat=chat, sender=request.user, content=content
1769
        )
1770

1771
        # Update chat's last_message_at
1772
        chat.last_message_at = message.created_at
1✔
1773
        chat.save(update_fields=["last_message_at"])
1✔
1774

1775
        # NOTIFY all other participants
1776
        payload = json.dumps(
1✔
1777
            {
1778
                "type": "new_message",
1779
                "chat_id": chat.id,
1780
                "message": {
1781
                    "id": message.id,
1782
                    "sender_email": message.sender.email,
1783
                    "content": message.content,
1784
                    "created_at": message.created_at.isoformat(),
1785
                },
1786
            }
1787
        )
1788

1789
        def send_notification():
1✔
NEW
1790
            for p in chat.participants.exclude(user=request.user):
×
NEW
1791
                try:
×
NEW
1792
                    requests.post(
×
1793
                        "http://127.0.0.1:8001/api/internal/publish/",
1794
                        json={"user_id": p.user_id, "payload": payload},
1795
                        timeout=1,
1796
                    )
NEW
1797
                except Exception:
×
NEW
1798
                    pass
×
1799

1800
        transaction.on_commit(send_notification)
1✔
1801

1802
        return JsonResponse(
1✔
1803
            {
1804
                "id": message.id,
1805
                "chat_id": chat.id,
1806
                "sender_id": message.sender.id,
1807
                "sender_email": message.sender.email,
1808
                "content": message.content,
1809
                "created_at": message.created_at.isoformat(),
1810
            },
1811
            status=201,
1812
        )
1813

1814
    except json.JSONDecodeError:
1✔
1815
        return JsonResponse({"error": "Invalid JSON"}, status=400)
1✔
1816
    except Exception as e:
×
1817
        return JsonResponse({"error": str(e)}, status=500)
×
1818

1819

1820
@csrf_exempt
1✔
1821
@require_http_methods(["POST"])
1✔
1822
def create_direct_chat_api(request):
1✔
1823
    """Create or get a direct message chat with another user."""
1824
    try:
1✔
1825
        if not request.user.is_authenticated:
1✔
1826
            return JsonResponse({"error": "Login required"}, status=401)
1✔
1827

1828
        data = json.loads(request.body)
1✔
1829
        recipient_email = (data.get("recipient_email") or "").strip()
1✔
1830

1831
        if not recipient_email:
1✔
1832
            return JsonResponse({"error": "recipient_email required"}, status=400)
1✔
1833

1834
        try:
1✔
1835
            recipient = CustomUser.objects.get(email=recipient_email)
1✔
1836
        except CustomUser.DoesNotExist:
1✔
1837
            return JsonResponse({"error": "Recipient user not found"}, status=404)
1✔
1838

1839
        if recipient.id == request.user.id:
1✔
1840
            return JsonResponse(
1✔
1841
                {"error": "Cannot create chat with yourself"}, status=400
1842
            )
1843

1844
        # Check if direct chat already exists between these two users
1845
        existing_chat = (
1✔
1846
            Chat.objects.filter(chat_type="direct", participants__user=request.user)
1847
            .filter(participants__user=recipient)
1848
            .first()
1849
        )
1850

1851
        if existing_chat:
1✔
1852
            return JsonResponse(
1✔
1853
                {
1854
                    "id": existing_chat.id,
1855
                    "chat_type": existing_chat.chat_type,
1856
                    "name": existing_chat.get_display_name(request.user),
1857
                    "created_at": existing_chat.created_at.isoformat(),
1858
                    "message": "Chat already exists",
1859
                },
1860
                status=200,
1861
            )
1862

1863
        # Create new direct chat
1864
        chat = Chat.objects.create(
1✔
1865
            chat_type="direct",
1866
            created_by=request.user,
1867
        )
1868

1869
        # Add both users as participants
1870
        ChatParticipant.objects.create(chat=chat, user=request.user)
1✔
1871
        ChatParticipant.objects.create(chat=chat, user=recipient)
1✔
1872

1873
        # NOTIFY the recipient
1874
        payload = json.dumps({"type": "new_message", "chat_id": chat.id})
1✔
1875

1876
        def send_notification():
1✔
NEW
1877
            try:
×
NEW
1878
                requests.post(
×
1879
                    "http://127.0.0.1:8001/api/internal/publish/",
1880
                    json={"user_id": recipient.id, "payload": payload},
1881
                    timeout=1,
1882
                )
NEW
1883
            except Exception:
×
NEW
1884
                pass
×
1885

1886
        transaction.on_commit(send_notification)
1✔
1887

1888
        return JsonResponse(
1✔
1889
            {
1890
                "id": chat.id,
1891
                "chat_type": chat.chat_type,
1892
                "name": chat.get_display_name(request.user),
1893
                "created_at": chat.created_at.isoformat(),
1894
                "message": "Chat created successfully",
1895
            },
1896
            status=201,
1897
        )
1898

1899
    except json.JSONDecodeError:
1✔
1900
        return JsonResponse({"error": "Invalid JSON"}, status=400)
1✔
1901
    except Exception as e:
×
1902
        return JsonResponse({"error": str(e)}, status=500)
×
1903

1904

1905
@csrf_exempt
1✔
1906
@require_http_methods(["POST"])
1✔
1907
def create_group_chat_api(request):
1✔
1908
    """Create a group chat, optionally with recent reviewers of an amenity."""
1909
    try:
1✔
1910
        if not request.user.is_authenticated:
1✔
1911
            return JsonResponse({"error": "Login required"}, status=401)
1✔
1912

1913
        data = json.loads(request.body)
1✔
1914
        chat_name = (data.get("chat_name") or "").strip()
1✔
1915
        amenity_id = data.get("amenity_id")
1✔
1916
        participant_emails = data.get("participant_emails", [])
1✔
1917

1918
        if not chat_name:
1✔
1919
            return JsonResponse({"error": "chat_name required"}, status=400)
1✔
1920

1921
        if not participant_emails:
1✔
1922
            return JsonResponse({"error": "participant_emails required"}, status=400)
1✔
1923

1924
        # Get participants
1925
        participants = CustomUser.objects.filter(email__in=participant_emails)
1✔
1926
        if participants.count() != len(participant_emails):
1✔
1927
            return JsonResponse(
1✔
1928
                {"error": "One or more participants not found"}, status=404
1929
            )
1930

1931
        # Ensure creator is in the participant list
1932
        if request.user not in participants:
1✔
1933
            participants = list(participants) + [request.user]
1✔
1934
        else:
1935
            participants = list(participants)
×
1936

1937
        # Get amenity if provided (for forum chats)
1938
        amenity = None
1✔
1939
        if amenity_id:
1✔
1940
            try:
1✔
1941
                amenity = Amenity.objects.get(external_id=amenity_id)
1✔
1942
            except Amenity.DoesNotExist:
1✔
1943
                try:
1✔
1944
                    amenity = Amenity.objects.get(id=amenity_id)
1✔
1945
                except (Amenity.DoesNotExist, ValueError):
1✔
1946
                    return JsonResponse({"error": "Amenity not found"}, status=404)
1✔
1947

1948
        # Create the group chat
1949
        chat_type = "amenity_forum" if amenity else "group"
1✔
1950
        chat = Chat.objects.create(
1✔
1951
            chat_type=chat_type,
1952
            amenity=amenity,
1953
            created_by=request.user,
1954
            name=chat_name,
1955
        )
1956

1957
        # Add participants
1958
        for participant in participants:
1✔
1959
            ChatParticipant.objects.create(chat=chat, user=participant)
1✔
1960

1961
        # NOTIFY other participants
1962
        payload = json.dumps({"type": "new_message", "chat_id": chat.id})
1✔
1963

1964
        def send_notification():
1✔
NEW
1965
            for participant in participants:
×
NEW
1966
                if participant.id != request.user.id:
×
NEW
1967
                    try:
×
NEW
1968
                        requests.post(
×
1969
                            "http://127.0.0.1:8001/api/internal/publish/",
1970
                            json={"user_id": participant.id, "payload": payload},
1971
                            timeout=1,
1972
                        )
NEW
1973
                    except Exception:
×
NEW
1974
                        pass
×
1975

1976
        transaction.on_commit(send_notification)
1✔
1977

1978
        return JsonResponse(
1✔
1979
            {
1980
                "id": chat.id,
1981
                "chat_type": chat.chat_type,
1982
                "name": chat.name,
1983
                "participant_count": len(participants),
1984
                "created_at": chat.created_at.isoformat(),
1985
                "message": "Group chat created successfully",
1986
            },
1987
            status=201,
1988
        )
1989

1990
    except json.JSONDecodeError:
1✔
1991
        return JsonResponse({"error": "Invalid JSON"}, status=400)
1✔
1992
    except Exception as e:
×
1993
        return JsonResponse({"error": str(e)}, status=500)
×
1994

1995

1996
@csrf_exempt
1✔
1997
@require_http_methods(["GET"])
1✔
1998
def get_chat_participants_api(request):
1✔
1999
    """Get participants for a specific chat."""
2000
    try:
1✔
2001
        if not request.user.is_authenticated:
1✔
2002
            return JsonResponse({"error": "Login required"}, status=401)
1✔
2003

2004
        chat_id = request.GET.get("chat_id")
1✔
2005
        if not chat_id:
1✔
2006
            return JsonResponse({"error": "chat_id parameter required"}, status=400)
1✔
2007

2008
        try:
1✔
2009
            chat = Chat.objects.get(id=chat_id)
1✔
2010
        except Chat.DoesNotExist:
1✔
2011
            return JsonResponse({"error": "Chat not found"}, status=404)
1✔
2012

2013
        if not chat.participants.filter(user=request.user).exists():
1✔
2014
            return JsonResponse(
1✔
2015
                {"error": "You are not a participant in this chat"}, status=403
2016
            )
2017

2018
        participants_data = [
1✔
2019
            {
2020
                "user_id": p.user.id if p.user else None,
2021
                "email": p.user.email if p.user else None,
2022
                "username": p.user.username or p.user.email,
2023
                "avatar_url": getattr(p.user, "avatar_url", None) or "",
2024
                "joined_at": p.joined_at.isoformat() if p.joined_at else None,
2025
            }
2026
            for p in chat.participants.select_related("user")
2027
        ]
2028

2029
        return JsonResponse(
1✔
2030
            {
2031
                "chat_id": chat.id,
2032
                "participants": participants_data,
2033
            }
2034
        )
2035
    except Exception as e:
×
2036
        return JsonResponse({"error": str(e)}, status=500)
×
2037

2038

2039
@csrf_exempt
1✔
2040
@login_required(login_url="/?auth_required=1")
1✔
2041
@require_http_methods(["POST"])
1✔
2042
def add_chat_participants_api(request):
1✔
2043
    """Add one or more participants to an existing group or forum chat."""
2044
    try:
1✔
2045
        data = json.loads(request.body)
1✔
2046
    except json.JSONDecodeError:
1✔
2047
        return JsonResponse({"error": "Invalid JSON"}, status=400)
1✔
2048

2049
    chat_id = data.get("chat_id")
1✔
2050
    participant_emails = data.get("participant_emails", [])
1✔
2051

2052
    if not chat_id:
1✔
2053
        return JsonResponse({"error": "chat_id required"}, status=400)
1✔
2054

2055
    if not participant_emails:
1✔
2056
        return JsonResponse({"error": "participant_emails required"}, status=400)
1✔
2057

2058
    try:
1✔
2059
        chat = Chat.objects.get(id=chat_id)
1✔
2060
    except Chat.DoesNotExist:
×
2061
        return JsonResponse({"error": "Chat not found"}, status=404)
×
2062

2063
    if not chat.participants.filter(user=request.user).exists():
1✔
2064
        return JsonResponse(
1✔
2065
            {"error": "You are not a participant in this chat"}, status=403
2066
        )
2067

2068
    if chat.chat_type == "direct":
1✔
2069
        return JsonResponse(
1✔
2070
            {"error": "Cannot add participants to a direct message chat"}, status=400
2071
        )
2072

2073
    existing_user_ids = set(chat.participants.values_list("user_id", flat=True))
1✔
2074

2075
    users_to_add = []
1✔
2076
    for email in participant_emails:
1✔
2077
        email = email.strip()
1✔
2078
        if not email:
1✔
2079
            continue
×
2080
        try:
1✔
2081
            user = CustomUser.objects.get(email=email)
1✔
2082
        except CustomUser.DoesNotExist:
1✔
2083
            return JsonResponse(
1✔
2084
                {"error": f"No user found with email '{email}'"}, status=404
2085
            )
2086
        if user.id in existing_user_ids:
1✔
2087
            return JsonResponse(
1✔
2088
                {"error": f"{email} is already a participant in this chat"}, status=400
2089
            )
2090
        users_to_add.append(user)
1✔
2091

2092
    if not users_to_add:
1✔
2093
        return JsonResponse({"error": "No valid new participants provided"}, status=400)
×
2094

2095
    for user in users_to_add:
1✔
2096
        ChatParticipant.objects.create(chat=chat, user=user)
1✔
2097

2098
    participants_data = [
1✔
2099
        {
2100
            "user_id": p.user.id if p.user else None,
2101
            "email": p.user.email if p.user else None,
2102
            "username": p.user.username or p.user.email,
2103
            "avatar_url": getattr(p.user, "avatar_url", None) or "",
2104
            "joined_at": p.joined_at.isoformat() if p.joined_at else None,
2105
        }
2106
        for p in chat.participants.select_related("user")
2107
    ]
2108

2109
    return JsonResponse(
1✔
2110
        {
2111
            "chat_id": chat.id,
2112
            "participants": participants_data,
2113
            "participant_count": len(participants_data),
2114
            "message": f"Added {len(users_to_add)} participant(s) successfully",
2115
        },
2116
        status=200,
2117
    )
2118

2119

2120
@csrf_exempt
1✔
2121
@require_http_methods(["POST"])
1✔
2122
def leave_chat_api(request):
1✔
2123
    """Leave a chat."""
2124
    try:
1✔
2125
        if not request.user.is_authenticated:
1✔
2126
            return JsonResponse({"error": "Login required"}, status=401)
1✔
2127

2128
        data = json.loads(request.body)
1✔
2129
        chat_id = data.get("chat_id")
1✔
2130
        if not chat_id:
1✔
2131
            return JsonResponse({"error": "chat_id required"}, status=400)
1✔
2132

2133
        ChatParticipant.objects.filter(chat_id=chat_id, user=request.user).delete()
1✔
2134

2135
        return JsonResponse({"message": "Successfully left the chat"})
1✔
2136
    except json.JSONDecodeError:
1✔
2137
        return JsonResponse({"error": "Invalid JSON"}, status=400)
1✔
2138
    except Exception as e:
×
2139
        return JsonResponse({"error": str(e)}, status=500)
×
2140

2141

2142
@require_http_methods(["GET"])
1✔
2143
def amenity_search_api(request):
1✔
2144
    """Search amenities by name for group chat creation."""
2145
    q = request.GET.get("q", "").strip()
1✔
2146
    limit = min(int(request.GET.get("limit", 10)), 20)
1✔
2147

2148
    if len(q) < 2:
1✔
2149
        return JsonResponse({"amenities": []})
1✔
2150

2151
    dynamodb = get_dynamodb_resource()
1✔
2152
    table = dynamodb.Table(settings.DYNAMODB_TABLE_NAME)
1✔
2153

2154
    try:
1✔
2155
        response = table.scan(
1✔
2156
            FilterExpression=Attr("PK").begins_with("AMENITY#")
2157
            & Attr("Name").contains(q)
2158
            & Attr("Active").eq(True)
2159
        )
2160

2161
        items = response.get("Items", [])
1✔
2162
        items = sorted(items, key=lambda x: x.get("Name", ""))[:limit]
1✔
2163

2164
        return JsonResponse(
1✔
2165
            {
2166
                "amenities": [
2167
                    {
2168
                        "id": a.get("Id"),
2169
                        "name": a.get("Name"),
2170
                        "address": a.get("Address", ""),
2171
                        "type": a.get("Type", ""),
2172
                    }
2173
                    for a in items
2174
                ]
2175
            }
2176
        )
NEW
2177
    except Exception as e:
×
NEW
2178
        return JsonResponse({"error": str(e)}, status=500)
×
2179

2180

2181
@require_http_methods(["GET"])
1✔
2182
@login_required(login_url="/?auth_required=1")
1✔
2183
def user_search_api(request):
1✔
2184
    """Search users by email or username for chat autocomplete."""
2185
    q = request.GET.get("q", "").strip()
×
2186
    limit = min(int(request.GET.get("limit", 10)), 20)
×
2187

2188
    if len(q) < 2:
×
2189
        return JsonResponse({"users": []})
×
2190

2191
    # Search matching users by email or username, excluding the current user
2192
    users = (
×
2193
        CustomUser.objects.filter(
2194
            Q(email__icontains=q) | Q(username__icontains=q), is_active=True
2195
        )
2196
        .exclude(id=request.user.id)
2197
        .order_by("email")[:limit]
2198
    )
2199

2200
    return JsonResponse({"users": [serialize_auth_user(u) for u in users]})
×
2201

2202

2203
@require_http_methods(["GET"])
1✔
2204
def get_amenity_reviewers_api(request):
1✔
2205
    """Get list of recent reviewers for an amenity (for starting group chats)."""
2206
    try:
1✔
2207
        amenity_id = request.GET.get("amenity_id")
1✔
2208
        limit = int(request.GET.get("limit", 10))
1✔
2209

2210
        if not amenity_id:
1✔
2211
            return JsonResponse({"error": "amenity_id parameter required"}, status=400)
1✔
2212

2213
        try:
1✔
2214
            sqlite_amenity = Amenity.objects.get(external_id=amenity_id)
1✔
2215
        except Amenity.DoesNotExist:
1✔
2216
            try:
1✔
2217
                sqlite_amenity = Amenity.objects.get(id=amenity_id)
1✔
2218
            except (Amenity.DoesNotExist, ValueError):
1✔
2219
                return JsonResponse({"error": "Amenity not found"}, status=404)
1✔
2220

2221
        reviews_qs = (
1✔
2222
            Review.objects.filter(amenity=sqlite_amenity, user__isnull=False)
2223
            .select_related("user")
2224
            .order_by("-created_at")[:limit]
2225
        )
2226

2227
        reviewers_data = [
1✔
2228
            {
2229
                "user_id": r.user.id,
2230
                "email": r.user.email,
2231
                "rating": r.rating,
2232
                "review_text": (r.review_text[:100] if r.review_text else None),
2233
                "created_at": r.created_at.isoformat(),
2234
            }
2235
            for r in reviews_qs
2236
        ]
2237

2238
        return JsonResponse(
1✔
2239
            {
2240
                "amenity_id": amenity_id,
2241
                "amenity_name": sqlite_amenity.name,
2242
                "reviewers": reviewers_data,
2243
                "total_reviewers": len(reviewers_data),
2244
            },
2245
            status=200,
2246
        )
2247
    except (ValueError, TypeError):
1✔
2248
        return JsonResponse({"error": "Invalid limit parameter"}, status=400)
1✔
2249
    except Exception as e:
×
2250
        return JsonResponse({"error": str(e)}, status=500)
×
2251

2252

2253
def availability_status_api(request, amenity_id):
1✔
2254
    """GET: return available/unavailable counts for the last 3 hours."""
2255
    cutoff = timezone.now() - timedelta(hours=AVAILABILITY_WINDOW_HOURS)
1✔
2256
    cutoff_iso = cutoff.isoformat()
1✔
2257

2258
    dynamodb = get_dynamodb_resource()
1✔
2259
    table = dynamodb.Table(settings.DYNAMODB_TABLE_NAME)
1✔
2260

2261
    amenity_response = table.get_item(
1✔
2262
        Key={"PK": f"AMENITY#{amenity_id}", "SK": f"AMENITY#{amenity_id}"}
2263
    )
2264
    if not amenity_response.get("Item"):
1✔
2265
        return JsonResponse({"error": "Not found"}, status=404)
1✔
2266

2267
    response = table.query(
1✔
2268
        KeyConditionExpression=Key("PK").eq(f"AMENITY#{amenity_id}")
2269
        & Key("SK").begins_with("AVAILABILITY#")
2270
    )
2271
    items = response.get("Items", [])
1✔
2272

2273
    recent_reports = [
1✔
2274
        item for item in items if item.get("ReportedAt", "") >= cutoff_iso
2275
    ]
2276

2277
    available_count = sum(1 for r in recent_reports if r.get("IsAvailable") is True)
1✔
2278
    unavailable_count = sum(1 for r in recent_reports if r.get("IsAvailable") is False)
1✔
2279

2280
    recent_reports.sort(key=lambda x: x.get("ReportedAt", ""), reverse=True)
1✔
2281
    latest = recent_reports[0] if recent_reports else None
1✔
2282

2283
    if not request.session.session_key:
1✔
2284
        request.session.create()
1✔
2285
    session_key = request.session.session_key or ""
1✔
2286

2287
    user_report = next(
1✔
2288
        (r for r in recent_reports if r["SK"] == f"AVAILABILITY#{session_key}"), None
2289
    )
2290
    user_vote = None
1✔
2291
    if user_report:
1✔
2292
        user_vote = "available" if user_report.get("IsAvailable") else "unavailable"
1✔
2293

2294
    return JsonResponse(
1✔
2295
        {
2296
            "available": available_count,
2297
            "unavailable": unavailable_count,
2298
            "total": available_count + unavailable_count,
2299
            "last_reported": latest.get("ReportedAt") if latest else None,
2300
            "user_vote": user_vote,
2301
            "window_hours": AVAILABILITY_WINDOW_HOURS,
2302
        }
2303
    )
2304

2305

2306
@csrf_exempt
1✔
2307
@require_http_methods(["POST"])
1✔
2308
def report_availability_api(request, amenity_id):
1✔
2309
    """POST: submit or change an availability report."""
2310
    dynamodb = get_dynamodb_resource()
1✔
2311
    table = dynamodb.Table(settings.DYNAMODB_TABLE_NAME)
1✔
2312

2313
    amenity_response = table.get_item(
1✔
2314
        Key={"PK": f"AMENITY#{amenity_id}", "SK": f"AMENITY#{amenity_id}"}
2315
    )
2316
    if not amenity_response.get("Item"):
1✔
UNCOV
2317
        return JsonResponse({"error": "Not found"}, status=404)
×
2318

2319
    try:
1✔
2320
        data = json.loads(request.body)
1✔
2321
    except (json.JSONDecodeError, ValueError):
×
2322
        return JsonResponse({"error": "Invalid JSON"}, status=400)
×
2323

2324
    is_available = data.get("is_available")
1✔
2325
    if is_available is None:
1✔
2326
        return JsonResponse({"error": "is_available is required"}, status=400)
1✔
2327

2328
    if not request.session.session_key:
1✔
2329
        request.session.create()
1✔
2330
    session_key = request.session.session_key or ""
1✔
2331

2332
    now_iso = timezone.now().isoformat()
1✔
2333
    # Utilize DynamoDB's Native TTL feature to auto-expire records after 48 hours
2334
    expires_at = int((timezone.now() + timedelta(hours=48)).timestamp())
1✔
2335

2336
    table.put_item(
1✔
2337
        Item={
2338
            "PK": f"AMENITY#{amenity_id}",
2339
            "SK": f"AVAILABILITY#{session_key}",
2340
            "IsAvailable": bool(is_available),
2341
            "ReportedAt": now_iso,
2342
            "ExpiresAt": expires_at,
2343
        }
2344
    )
2345

2346
    cutoff = timezone.now() - timedelta(hours=AVAILABILITY_WINDOW_HOURS)
1✔
2347
    cutoff_iso = cutoff.isoformat()
1✔
2348

2349
    response = table.query(
1✔
2350
        KeyConditionExpression=Key("PK").eq(f"AMENITY#{amenity_id}")
2351
        & Key("SK").begins_with("AVAILABILITY#")
2352
    )
2353
    items = response.get("Items", [])
1✔
2354
    recent_reports = [
1✔
2355
        item for item in items if item.get("ReportedAt", "") >= cutoff_iso
2356
    ]
2357

2358
    available_count = sum(1 for r in recent_reports if r.get("IsAvailable") is True)
1✔
2359
    unavailable_count = sum(1 for r in recent_reports if r.get("IsAvailable") is False)
1✔
2360

2361
    return JsonResponse(
1✔
2362
        {
2363
            "ok": True,
2364
            "available": available_count,
2365
            "unavailable": unavailable_count,
2366
            "total": available_count + unavailable_count,
2367
            "user_vote": "available" if is_available else "unavailable",
2368
        }
2369
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc