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

gcivil-nyu-org / team4-wed-fall25 / 111

07 Dec 2025 02:07AM UTC coverage: 91.325% (+0.5%) from 90.869%
111

Pull #111

travis-pro

web-flow
Merge 836c68e97 into 134a98b95
Pull Request #111: fixed all the issues

42 of 68 new or added lines in 4 files covered. (61.76%)

1 existing line in 1 file now uncovered.

2095 of 2294 relevant lines covered (91.33%)

0.91 hits per line

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

73.05
/note2webapp/views.py
1
import os
1✔
2
import json
1✔
3
import importlib
1✔
4
import importlib.util
1✔
5
import inspect
1✔
6

7
from django.contrib import messages
1✔
8
from django.contrib.auth import login, logout, authenticate
1✔
9
from django.contrib.auth.decorators import login_required
1✔
10
from django.contrib.auth.models import Group, User
1✔
11
from django.shortcuts import render, redirect, get_object_or_404
1✔
12
from django.http import JsonResponse
1✔
13
from django.urls import reverse
1✔
14
from django.utils import timezone
1✔
15
from django import forms
1✔
16
from django.db import transaction, IntegrityError, models
1✔
17

18
# for admin stats
19
from django.contrib.admin.views.decorators import staff_member_required
1✔
20
from django.db.models import Count
1✔
21

22
from django.conf import settings
1✔
23
from django.views.decorators.http import require_POST
1✔
24
from django.views.decorators.csrf import csrf_exempt
1✔
25

26
from .forms import UploadForm, VersionForm
1✔
27
from .models import ModelUpload, ModelVersion, Profile, ModelComment, CommentReaction
1✔
28
from .utils import (
1✔
29
    validate_model,
30
    test_model_on_cpu,
31
    sha256_uploaded_file,
32
    sha256_file_path,
33
    delete_version_files_and_dir,
34
    delete_model_media_tree,
35
)
36
from .decorators import role_required
1✔
37
from openai import OpenAI
1✔
38

39

40
# ---------------------------------------------------------
41
# AUTH
42
# ---------------------------------------------------------
43
def signup_view(request):
1✔
44
    if request.method == "POST":
1✔
45
        username = request.POST.get("username")
1✔
46
        password1 = request.POST.get("password1")
1✔
47
        password2 = request.POST.get("password2")
1✔
48

49
        errors = []
1✔
50
        if not username or not password1 or not password2:
1✔
51
            errors.append("All fields are required.")
1✔
52
        if password1 and password2 and password1 != password2:
1✔
53
            errors.append("Passwords do not match.")
1✔
54
        if User.objects.filter(username=username).exists():
1✔
55
            errors.append("Username already exists.")
1✔
56
        if password1 and len(password1) < 8:
1✔
57
            errors.append("Password must be at least 8 characters long.")
1✔
58

59
        if errors:
1✔
60
            for e in errors:
1✔
61
                messages.error(request, e)
1✔
62
            return render(request, "note2webapp/login.html")
1✔
63

64
        try:
1✔
65
            with transaction.atomic():
1✔
66
                user = User.objects.create_user(username=username, password=password1)
1✔
67

68
                # If a signal already created Profile, this will just fetch it.
69
                profile, created = Profile.objects.get_or_create(
1✔
70
                    user=user, defaults={"role": "uploader"}
71
                )
72
                # Ensure normal signups end up as uploader (unless something else set it)
73
                if profile.role != "uploader" and not user.is_superuser:
1✔
74
                    profile.role = "uploader"
×
75
                    profile.save(update_fields=["role"])
×
76

77
                group, _ = Group.objects.get_or_create(name="ModelUploader")
1✔
78
                user.groups.add(group)
1✔
79

80
            messages.success(request, "Account created successfully! Please login.")
1✔
81
            return redirect("login")
1✔
82

83
        except IntegrityError:
1✔
84
            # Handles any rare race (double post / parallel signal, etc.)
85
            messages.error(
1✔
86
                request,
87
                "Account created but there was a profile setup conflict; please try logging in.",
88
            )
89
            return render(request, "note2webapp/login.html")
1✔
90

91
        except Exception as e:
1✔
92
            messages.error(request, f"Error creating account: {str(e)}")
1✔
93
            return render(request, "note2webapp/login.html")
1✔
94

95
    return render(request, "note2webapp/login.html")
1✔
96

97

98
def login_view(request):
1✔
99
    if request.method == "POST":
1✔
100
        username = request.POST.get("username")
1✔
101
        password = request.POST.get("password")
1✔
102
        user = authenticate(request, username=username, password=password)
1✔
103

104
        if user is not None:
1✔
105
            login(request, user)
1✔
106

107
            # Ensure superusers have admin role
108
            if user.is_superuser:
1✔
109
                prof, _ = Profile.objects.get_or_create(user=user)
1✔
110
                if prof.role != "admin":
1✔
111
                    prof.role = "admin"
×
112
                    prof.save()
×
113

114
                # Use different session key for admin
115
                request.session.set_expiry(3600)  # 1 hour for admins
1✔
116

117
            # Redirect based on role
118
            if hasattr(user, "profile") and user.profile.role == "reviewer":
1✔
119
                return redirect("reviewer_dashboard")
1✔
120
            return redirect("dashboard")
1✔
121
        else:
122
            messages.error(request, "Invalid username or password.")
1✔
123

124
    return render(request, "note2webapp/login.html")
1✔
125

126

127
def logout_view(request):
1✔
128
    logout(request)
1✔
129
    messages.success(request, "You have been logged out successfully.")
1✔
130
    return redirect("login")
1✔
131

132

133
# ---------------------------------------------------------
134
# MAIN DASHBOARD ROUTER
135
# ---------------------------------------------------------
136
@login_required
1✔
137
def dashboard(request):
1✔
138
    """
139
    Decide where to send the user.
140
    - Django staff/superuser: our nice stats page
141
    - uploader: uploader dashboard
142
    - reviewer: reviewer dashboard
143
    """
144
    if request.user.is_staff:
1✔
145
        return redirect("admin_stats")
1✔
146

147
    role = getattr(request.user.profile, "role", "uploader")
1✔
148
    if role == "uploader":
1✔
149
        return model_uploader_dashboard(request)
1✔
150
    elif role == "reviewer":
1✔
151
        return reviewer_dashboard(request)
1✔
152

153
    # fallback
154
    return model_uploader_dashboard(request)
×
155

156

157
@login_required
1✔
158
def validation_failed(request, version_id):
1✔
159
    version = get_object_or_404(ModelVersion, id=version_id, upload__user=request.user)
1✔
160
    if version.status != "FAIL":
1✔
161
        return redirect("model_versions", model_id=version.upload.id)
1✔
162
    return render(request, "note2webapp/validation_failed.html", {"version": version})
1✔
163

164

165
# ---------------------------------------------------------
166
# UPLOADER DASHBOARD
167
# ---------------------------------------------------------
168
@login_required
1✔
169
def model_uploader_dashboard(request):
1✔
170
    """
171
    Handles:
172
      - /dashboard/?page=list
173
      - /dashboard/?page=create
174
      - /dashboard/?page=detail&pk=...
175
      - /dashboard/?page=add_version&pk=...
176
    """
177
    page = request.GET.get("page", "list")
1✔
178
    pk = request.GET.get("pk")
1✔
179

180
    uploads = ModelUpload.objects.filter(user=request.user).order_by("-created_at")
1✔
181
    for upload in uploads:
1✔
182
        upload.active_versions_count = upload.versions.filter(
1✔
183
            is_deleted=False, status="PASS"
184
        ).count()
185

186
    context = {"uploads": uploads, "page": page}
1✔
187

188
    # 1) CREATE MODEL
189
    if page == "create":
1✔
190
        if request.method == "POST":
1✔
191
            form = UploadForm(request.POST)
1✔
192
            if form.is_valid():
1✔
193
                model_name = form.cleaned_data["name"]
1✔
194
                if ModelUpload.objects.filter(
1✔
195
                    user=request.user, name=model_name
196
                ).exists():
197
                    messages.error(
1✔
198
                        request,
199
                        f"A model with the name '{model_name}' already exists. Please choose a different name.",
200
                    )
201
                    context["form"] = form
1✔
202
                    return render(request, "note2webapp/home.html", context)
1✔
203
                upload = form.save(commit=False)
1✔
204
                upload.user = request.user
1✔
205
                upload.save()
1✔
206
                messages.success(request, f"Model '{model_name}' created successfully!")
1✔
207
                return redirect(f"/dashboard/?page=detail&pk={upload.pk}")
1✔
208
        else:
209
            form = UploadForm()
1✔
210
        context["form"] = form
1✔
211

212
    # 2) MODEL DETAIL
213
    elif page == "detail" and pk:
1✔
214
        upload = get_object_or_404(ModelUpload, pk=pk, user=request.user)
1✔
215
        versions = upload.versions.all().order_by("-created_at")
1✔
216
        version_counts = {
1✔
217
            "total": versions.count(),
218
            "active": versions.filter(
219
                is_active=True, is_deleted=False, status="PASS"
220
            ).count(),
221
            "available": versions.filter(is_deleted=False, status="PASS").count(),
222
            "failed": versions.filter(is_deleted=False, status="FAIL").count(),
223
            "deleted": versions.filter(is_deleted=True).count(),
224
        }
225
        context.update(
1✔
226
            {"upload": upload, "versions": versions, "version_counts": version_counts}
227
        )
228

229
    # 3) ADD VERSION
230
    elif page == "add_version" and pk:
1✔
231
        upload = get_object_or_404(ModelUpload, pk=pk, user=request.user)
1✔
232
        retry_version_id = request.GET.get("retry")
1✔
233

234
        if request.method == "POST":
1✔
235
            # quick missing file check
236
            missing_files = []
1✔
237
            if not request.FILES.get("model_file"):
1✔
238
                missing_files.append("Model file (.pt)")
1✔
239
            if not request.FILES.get("predict_file"):
1✔
240
                missing_files.append("Predict file (.py)")
1✔
241
            if not request.FILES.get("schema_file"):
1✔
242
                missing_files.append("Schema file (.json)")
1✔
243
            if missing_files:
1✔
244
                messages.error(
1✔
245
                    request, f"Missing required files: {', '.join(missing_files)}"
246
                )
247
                form = VersionForm(
1✔
248
                    initial={
249
                        "tag": request.POST.get("tag", ""),
250
                        "category": request.POST.get("category", "sentiment"),
251
                    }
252
                )
253
                context.update(
1✔
254
                    {
255
                        "form": form,
256
                        "upload": upload,
257
                        "retry_version_id": (
258
                            retry_version_id if retry_version_id else None
259
                        ),
260
                        "page": "add_version",
261
                        "pk": pk,
262
                    }
263
                )
264
                return render(request, "note2webapp/home.html", context)
1✔
265

266
            form = VersionForm(request.POST, request.FILES)
1✔
267
            if form.is_valid():
1✔
268
                # 1. Hash incoming files
269
                incoming_model_hash = sha256_uploaded_file(request.FILES["model_file"])
1✔
270
                incoming_predict_hash = sha256_uploaded_file(
1✔
271
                    request.FILES["predict_file"]
272
                )
273
                incoming_schema_hash = sha256_uploaded_file(
1✔
274
                    request.FILES["schema_file"]
275
                )
276

277
                # 2. Compare to every non-deleted version's stored files
278
                duplicate_found = False
1✔
279
                for v in ModelVersion.objects.filter(is_deleted=False):
1✔
280
                    try:
1✔
281
                        if (
1✔
282
                            v.model_file
283
                            and v.predict_file
284
                            and v.schema_file
285
                            and os.path.isfile(v.model_file.path)
286
                            and os.path.isfile(v.predict_file.path)
287
                            and os.path.isfile(v.schema_file.path)
288
                        ):
289
                            if (
1✔
290
                                sha256_file_path(v.model_file.path)
291
                                == incoming_model_hash
292
                                and sha256_file_path(v.predict_file.path)
293
                                == incoming_predict_hash
294
                                and sha256_file_path(v.schema_file.path)
295
                                == incoming_schema_hash
296
                            ):
297
                                duplicate_found = True
×
298
                                break
×
299
                    except Exception:
×
300
                        continue
×
301

302
                if duplicate_found:
1✔
303
                    msg_text = (
×
304
                        "An identical model/predict/schema bundle is already present. "
305
                        "Please upload a new version or change the files."
306
                    )
307
                    messages.error(request, msg_text)
×
308
                    context.update(
×
309
                        {
310
                            "form": form,
311
                            "upload": upload,
312
                            "duplicate_error": msg_text,
313
                        }
314
                    )
315
                    return render(request, "note2webapp/home.html", context)
×
316

317
                # 3. Create or retry the version
318
                if retry_version_id:
1✔
319
                    try:
×
320
                        version = ModelVersion.objects.get(
×
321
                            id=retry_version_id,
322
                            upload=upload,
323
                            status="FAIL",
324
                            is_deleted=False,
325
                        )
326
                        version.model_file = form.cleaned_data["model_file"]
×
327
                        version.predict_file = form.cleaned_data["predict_file"]
×
328
                        version.schema_file = form.cleaned_data["schema_file"]
×
329
                        version.category = form.cleaned_data["category"]
×
330
                        version.information = form.cleaned_data["information"]
×
331
                        version.status = "PENDING"
×
332
                        version.log = ""
×
333
                        version.save()
×
334
                        messages.info(
×
335
                            request, f"Retrying upload for version '{version.tag}'"
336
                        )
337
                    except ModelVersion.DoesNotExist:
×
338
                        messages.error(request, "Invalid version to retry")
×
339
                        return redirect(f"/dashboard/?page=detail&pk={upload.pk}")
×
340
                else:
341
                    version = form.save(commit=False)
1✔
342
                    version.upload = upload
1✔
343
                    version.save()
1✔
344

345
                # 4. validate
346
                validate_model(version)
1✔
347

348
                if version.status == "FAIL":
1✔
349
                    return redirect("validation_failed", version_id=version.id)
×
350

351
                if not retry_version_id:
1✔
352
                    messages.success(
1✔
353
                        request, f"Version '{version.tag}' uploaded successfully!"
354
                    )
355
                return redirect(f"/dashboard/?page=detail&pk={upload.pk}")
1✔
356

357
            else:
358
                messages.error(request, "Please correct the errors below.")
1✔
359
        else:
360
            # GET
361
            initial = {}
1✔
362
            if retry_version_id:
1✔
363
                try:
1✔
364
                    retry_version = ModelVersion.objects.get(
1✔
365
                        id=retry_version_id,
366
                        upload=upload,
367
                        status="FAIL",
368
                        is_deleted=False,
369
                    )
370
                    initial = {
×
371
                        "tag": retry_version.tag,
372
                        "category": retry_version.category,
373
                    }
374
                    context["retrying"] = True
×
375
                except ModelVersion.DoesNotExist:
1✔
376
                    messages.error(request, "Invalid version to retry")
1✔
377
                    return redirect(f"/dashboard/?page=detail&pk={upload.pk}")
1✔
378
            form = VersionForm(initial=initial)
×
379

380
        context.update(
1✔
381
            {
382
                "form": form,
383
                "upload": upload,
384
                "retry_version_id": retry_version_id if retry_version_id else None,
385
            }
386
        )
387

388
    return render(request, "note2webapp/home.html", context)
1✔
389

390

391
# ---------------------------------------------------------
392
# REVIEWER DASHBOARD
393
# ---------------------------------------------------------
394
@role_required("reviewer")
1✔
395
def reviewer_dashboard(request):
1✔
396
    page = request.GET.get("page", "list")
1✔
397
    pk = request.GET.get("pk")
1✔
398
    context = {"page": page}
1✔
399

400
    if page == "list":
1✔
401
        uploads = (
1✔
402
            ModelUpload.objects.filter(
403
                versions__is_active=True,
404
                versions__status="PASS",
405
                versions__is_deleted=False,
406
            )
407
            .distinct()
408
            .order_by("-created_at")
409
        )
410
        for upload in uploads:
1✔
411
            upload.active_version = upload.versions.filter(
×
412
                is_active=True, status="PASS", is_deleted=False
413
            ).first()
414
        context["uploads"] = uploads
1✔
415
        return render(request, "note2webapp/reviewer.html", context)
1✔
416

417
    if page == "detail" and pk:
1✔
418
        upload = get_object_or_404(ModelUpload, pk=pk)
×
419
        active_version = upload.versions.filter(
×
420
            is_active=True, status="PASS", is_deleted=False
421
        ).first()
422
        if not active_version:
×
423
            messages.warning(request, "This model has no active version.")
×
424
            return redirect("/reviewer/?page=list")
×
425
        context.update({"upload": upload, "active_version": active_version})
×
426
        return render(request, "note2webapp/reviewer.html", context)
×
427

428
    if page == "add_feedback" and pk:
1✔
429
        version = get_object_or_404(ModelVersion, pk=pk)
1✔
430
        if request.method == "POST":
1✔
431
            comment = request.POST.get("comment", "").strip()
1✔
432
            if comment:
1✔
433
                messages.success(request, "Feedback submitted successfully!")
1✔
434
                return redirect(f"/reviewer/?page=detail&pk={version.upload.pk}")
1✔
435
            else:
436
                messages.error(request, "Please provide feedback comment.")
1✔
437
        context["version"] = version
1✔
438
        return render(request, "note2webapp/reviewer.html", context)
1✔
439

440
    return redirect("/reviewer/?page=list")
1✔
441

442

443
# ---------------------------------------------------------
444
# VERSION SOFT DELETE
445
# ---------------------------------------------------------
446
@login_required
1✔
447
def soft_delete_version(request, version_id):
1✔
448
    version = get_object_or_404(ModelVersion, id=version_id)
1✔
449

450
    # permissions
451
    if request.user != version.upload.user and not request.user.is_staff:
1✔
452
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
1✔
453
            return JsonResponse(
×
454
                {"success": False, "error": "Permission denied"}, status=403
455
            )
456
        messages.error(request, "You don't have permission to delete this version.")
1✔
457
        return redirect("dashboard")
1✔
458

459
    # if it's active and there are other versions -> block
460
    if version.is_active:
1✔
461
        other_versions = ModelVersion.objects.filter(
1✔
462
            upload=version.upload, is_deleted=False
463
        ).exclude(id=version_id)
464
        if other_versions.exists():
1✔
465
            if request.headers.get("X-Requested-With") == "XMLHttpRequest":
×
466
                return JsonResponse(
×
467
                    {
468
                        "success": False,
469
                        "error": "Cannot delete active version. Please activate another version first.",
470
                    },
471
                    status=400,
472
                )
473
            messages.error(
×
474
                request,
475
                "Cannot delete active version. Please activate another version first.",
476
            )
477
            return redirect("dashboard")
×
478

479
    if request.method == "POST":
1✔
480
        version.is_deleted = True
1✔
481
        version.deleted_at = timezone.now()
1✔
482
        version.is_active = False
1✔
483
        version.save()
1✔
484

485
        # physically remove files + folder
486
        delete_version_files_and_dir(version)
1✔
487

488
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
1✔
489
            return JsonResponse(
1✔
490
                {
491
                    "success": True,
492
                    "message": f"Version (Tag: {version.tag}) deleted successfully",
493
                    "reload": True,
494
                }
495
            )
496

497
        messages.success(
1✔
498
            request, f"Version with tag '{version.tag}' has been deleted successfully."
499
        )
500
        return redirect(f"/model-versions/{version.upload.id}/")
1✔
501

502
    return redirect("dashboard")
×
503

504

505
# ---------------------------------------------------------
506
# ACTIVATE / DEPRECATE VERSION
507
# ---------------------------------------------------------
508
@login_required
1✔
509
def activate_version(request, version_id):
1✔
510
    version = get_object_or_404(ModelVersion, id=version_id)
1✔
511
    if request.user != version.upload.user and not request.user.is_staff:
1✔
512
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
×
513
            return JsonResponse(
×
514
                {
515
                    "success": False,
516
                    "error": "You don't have permission to activate this version.",
517
                },
518
                status=403,
519
            )
520
        messages.error(request, "You don't have permission to activate this version.")
×
521
        return redirect("dashboard")
×
522

523
    if version.is_deleted:
1✔
524
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
1✔
525
            return JsonResponse(
1✔
526
                {"success": False, "error": "Cannot activate a deleted version."},
527
                status=400,
528
            )
529
        messages.error(request, "Cannot activate a deleted version.")
1✔
530
        return redirect("model_versions", model_id=version.upload.id)
1✔
531

532
    if version.status != "PASS":
1✔
533
        status_msg = (
1✔
534
            "pending validation"
535
            if version.status == "PENDING"
536
            else "that failed validation"
537
        )
538
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
1✔
539
            return JsonResponse(
1✔
540
                {
541
                    "success": False,
542
                    "error": f"Cannot activate a version that is {status_msg}.",
543
                },
544
                status=400,
545
            )
546
        messages.error(
1✔
547
            request,
548
            f"Cannot activate a version that is {status_msg}. Please wait for validation to complete or upload a new version.",
549
        )
550
        return redirect("model_versions", model_id=version.upload.id)
1✔
551

552
    # deactivate all versions of this model, then activate this one
553
    ModelVersion.objects.filter(upload=version.upload).update(is_active=False)
×
554
    version.is_active = True
×
555
    version.save()
×
556

557
    if request.headers.get("X-Requested-With") == "XMLHttpRequest":
×
558
        return JsonResponse(
×
559
            {"success": True, "message": f"Version '{version.tag}' is now active."}
560
        )
561

562
    messages.success(
×
563
        request,
564
        f"Version '{version.tag}' is now active. Other versions have been deactivated.",
565
    )
566
    return redirect("model_versions", model_id=version.upload.id)
×
567

568

569
@login_required
1✔
570
def deprecate_version(request, version_id):
1✔
571
    version = get_object_or_404(ModelVersion, id=version_id)
1✔
572
    if request.user != version.upload.user and not request.user.is_staff:
1✔
573
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
×
574
            return JsonResponse(
×
575
                {"success": False, "error": "Permission denied"}, status=403
576
            )
577
        messages.error(request, "You don't have permission to deprecate this version.")
×
578
        return redirect("dashboard")
×
579

580
    if version.is_deleted:
1✔
581
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
×
582
            return JsonResponse(
×
583
                {"success": False, "error": "Cannot deprecate deleted version"},
584
                status=400,
585
            )
586
        messages.error(request, "Cannot deprecate a deleted version.")
×
587
        return redirect("dashboard")
×
588

589
    if request.method == "POST":
1✔
590
        version.is_active = False
1✔
591
        version.save()
1✔
592
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
1✔
593
            return JsonResponse(
×
594
                {
595
                    "success": True,
596
                    "message": f'Version with tag "{version.tag}" has been deprecated (deactivated)',
597
                    "version_id": version.id,
598
                }
599
            )
600
        messages.success(
1✔
601
            request, f"Version with tag '{version.tag}' has been deprecated."
602
        )
603
        return redirect(f"/model-versions/{version.upload.id}/")
1✔
604

605
    return redirect("dashboard")
×
606

607

608
# ---------------------------------------------------------
609
# DELETE MODEL
610
# ---------------------------------------------------------
611
@login_required
1✔
612
def delete_model(request, model_id):
1✔
613
    model_upload = get_object_or_404(ModelUpload, id=model_id)
1✔
614

615
    if request.user != model_upload.user and not request.user.is_staff:
1✔
616
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
×
617
            return JsonResponse(
×
618
                {"success": False, "error": "Permission denied"}, status=403
619
            )
620
        messages.error(request, "You don't have permission to delete this model.")
×
621
        return redirect("dashboard")
×
622

623
    # only count non-deleted versions
624
    remaining = ModelVersion.objects.filter(
1✔
625
        upload=model_upload, is_deleted=False
626
    ).count()
627

628
    if remaining > 0:
1✔
629
        msg = f"Cannot delete model with {remaining} active versions. Please delete all versions first."
1✔
630
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
1✔
631
            return JsonResponse({"success": False, "error": msg}, status=400)
×
632
        messages.error(request, msg)
1✔
633
        return redirect("dashboard")
1✔
634

635
    if request.method == "POST":
1✔
636
        model_name = model_upload.name
1✔
637
        delete_model_media_tree(model_upload)
1✔
638
        model_upload.delete()
1✔
639

640
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
1✔
641
            return JsonResponse(
×
642
                {
643
                    "success": True,
644
                    "message": f'Model "{model_name}" has been permanently deleted.',
645
                }
646
            )
647
        messages.success(request, f'Model "{model_name}" has been permanently deleted.')
1✔
648
        return redirect("dashboard")
1✔
649

650
    return redirect("dashboard")
×
651

652

653
# ---------------------------------------------------------
654
# MODEL VERSIONS PAGE
655
# ---------------------------------------------------------
656
@login_required
1✔
657
def model_versions(request, model_id):
1✔
658
    model_upload = get_object_or_404(ModelUpload, pk=model_id, user=request.user)
1✔
659
    versions = model_upload.versions.all().order_by("-created_at")
1✔
660

661
    context = {
1✔
662
        "model_upload": model_upload,
663
        "versions": versions,
664
        "total_count": versions.count(),
665
        "active_count": versions.filter(
666
            is_active=True, is_deleted=False, status="PASS"
667
        ).count(),
668
        "available_count": versions.filter(is_deleted=False, status="PASS").count(),
669
        "failed_count": versions.filter(is_deleted=False, status="FAIL").count(),
670
        "deleted_count": versions.filter(is_deleted=True).count(),
671
    }
672
    return render(request, "note2webapp/model_versions.html", context)
1✔
673

674

675
# ---------------------------------------------------------
676
# EDIT VERSION INFORMATION
677
# ---------------------------------------------------------
678
class VersionInformationForm(forms.ModelForm):
1✔
679
    class Meta:
1✔
680
        model = ModelVersion
1✔
681
        fields = ["information"]
1✔
682
        widgets = {
1✔
683
            "information": forms.Textarea(
684
                attrs={
685
                    "rows": 6,
686
                    "placeholder": "Enter information about this model version...",
687
                }
688
            )
689
        }
690

691
    def __init__(self, *args, **kwargs):
1✔
692
        super().__init__(*args, **kwargs)
1✔
693
        self.fields["information"].required = True
1✔
694
        self.fields["information"].label = "Model Information"
1✔
695

696

697
@login_required
1✔
698
def edit_version_information(request, version_id):
1✔
699
    version = get_object_or_404(ModelVersion, id=version_id)
1✔
700
    if version.upload.user != request.user:
1✔
701
        messages.error(request, "You don't have permission to edit this version.")
×
702
        return redirect("dashboard")
×
703

704
    if request.method == "POST":
1✔
705
        form = VersionInformationForm(request.POST, instance=version)
1✔
706
        if form.is_valid():
1✔
707
            form.save()
1✔
708
            messages.success(
1✔
709
                request, f"Information for version {version.tag} updated successfully!"
710
            )
711
            return redirect("model_versions", model_id=version.upload.id)
1✔
712
    else:
713
        form = VersionInformationForm(instance=version)
1✔
714

715
    return render(
1✔
716
        request,
717
        "note2webapp/edit_version_information.html",
718
        {"form": form, "version": version},
719
    )
720

721

722
# ---------------------------------------------------------
723
# TEST MODEL (CPU)
724
# ---------------------------------------------------------
725
@login_required
1✔
726
def test_model_cpu(request, version_id):
1✔
727
    version = get_object_or_404(ModelVersion, id=version_id)
1✔
728

729
    result = None
1✔
730
    parse_error = None
1✔
731
    last_input = ""
1✔
732

733
    if request.method == "POST":
1✔
734
        raw_input = request.POST.get("input_data", "").strip()
1✔
735
        last_input = raw_input
1✔
736

737
        if raw_input:
1✔
738
            try:
1✔
739
                parsed = json.loads(raw_input)
1✔
740

741
                if isinstance(parsed, list):
1✔
742
                    outputs = []
×
743
                    for item in parsed:
×
744
                        if not isinstance(item, dict):
×
745
                            outputs.append(
×
746
                                {
747
                                    "status": "error",
748
                                    "error": 'Each item in the list must be a JSON object like {"text": "..."}',
749
                                }
750
                            )
751
                        else:
752
                            outputs.append(test_model_on_cpu(version, item))
×
753
                    result = {"status": "ok", "batch": True, "outputs": outputs}
×
754

755
                elif isinstance(parsed, dict):
1✔
756
                    result = test_model_on_cpu(version, parsed)
1✔
757

758
                else:
759
                    parse_error = (
×
760
                        "Top-level JSON must be either an object {...} or a list [...]."
761
                    )
762
                # Increment usage counter every time it's tested
763
                version.usage_count = models.F("usage_count") + 1
1✔
764
                version.save(update_fields=["usage_count"])
1✔
765
                version.refresh_from_db()
1✔
766

767
            except json.JSONDecodeError as e:
1✔
768
                raw_msg = str(e)
1✔
769
                stripped = raw_input.lstrip()
1✔
770
                friendly = raw_msg
1✔
771

772
                if raw_msg.startswith("Extra data"):
1✔
773
                    friendly = (
×
774
                        "Your JSON has extra data. Did you forget to wrap it in { ... } ? "
775
                        'Example: {"text": "Party"}'
776
                    )
777
                elif raw_msg.startswith(
1✔
778
                    "Expecting property name enclosed in double quotes"
779
                ):
780
                    friendly = 'Invalid JSON: Property names must be in double quotes. Example: {"text": "Hello"}'
1✔
781
                elif raw_msg.startswith("Expecting value") and stripped.startswith("{"):
×
782
                    friendly = 'Invalid JSON: String values must be in double quotes. Example: {"text": "Hello"}'
×
783
                elif (
×
784
                    raw_msg.startswith("Expecting value")
785
                    and not stripped.startswith("{")
786
                    and not stripped.startswith("[")
787
                ):
788
                    friendly = (
×
789
                        "JSON must start with { ... } (object) or [ ... ] (list). "
790
                        'Example: {"text": "Hello"}'
791
                    )
792
                elif raw_msg.startswith("Unterminated string starting at"):
×
793
                    friendly = "You have an opening quote without a closing quote."
×
794
                elif raw_msg.startswith("Expecting ',' delimiter"):
×
795
                    friendly = "You may be missing a comma between fields."
×
796

797
                parse_error = friendly
1✔
798

799
    # Get comments for this version
800
    comments = ModelComment.objects.filter(
1✔
801
        model_version=version, parent=None
802
    ).prefetch_related(
803
        "replies",
804
        "replies__user__profile",
805
        "replies__reactions",
806
        "user__profile",
807
        "reactions",
808
    )
809

810
    # Check if user is uploader
811
    is_uploader = version.upload.user == request.user
1✔
812

813
    # Get user reactions for all comments and replies
814
    comment_ids = [c.id for c in comments]
1✔
815
    for comment in comments:
1✔
816
        comment_ids.extend([r.id for r in comment.replies.all()])
×
817

818
    user_reactions = {}
1✔
819
    if comment_ids:
1✔
820
        reactions = CommentReaction.objects.filter(
×
821
            comment_id__in=comment_ids, user=request.user
822
        )
823
        for reaction in reactions:
×
824
            user_reactions[reaction.comment_id] = reaction.reaction_type
×
825

826
    # Add UTC timestamps to version and comments/replies for JavaScript conversion
827
    # Version uploaded timestamp
828
    if timezone.is_aware(version.created_at):
1✔
829
        version.created_at_utc = version.created_at.isoformat()
1✔
830
    else:
831
        version.created_at_utc = timezone.make_aware(
×
832
            version.created_at, timezone.utc
833
        ).isoformat()
834

835
    # Comments and replies timestamps
836
    for comment in comments:
1✔
837
        # Ensure timezone-aware datetime
838
        if timezone.is_aware(comment.created_at):
×
839
            comment.created_at_utc = comment.created_at.isoformat()
×
840
        else:
841
            # If naive, assume UTC
842
            comment.created_at_utc = timezone.make_aware(
×
843
                comment.created_at, timezone.utc
844
            ).isoformat()
845

846
        for reply in comment.replies.all():
×
847
            if timezone.is_aware(reply.created_at):
×
848
                reply.created_at_utc = reply.created_at.isoformat()
×
849
            else:
850
                reply.created_at_utc = timezone.make_aware(
×
851
                    reply.created_at, timezone.utc
852
                ).isoformat()
853

854
    return render(
1✔
855
        request,
856
        "note2webapp/test_model.html",
857
        {
858
            "version": version,
859
            "result": result,
860
            "last_input": last_input,
861
            "parse_error": parse_error,
862
            "comments": comments,
863
            "is_uploader": is_uploader,
864
            "user_reactions": json.dumps(user_reactions),
865
        },
866
    )
867

868

869
# ---------------------------------------------------------
870
# SIMPLE API HOOKS
871
# ---------------------------------------------------------
872
@login_required
1✔
873
def run_model_from_path(request):
1✔
874
    if request.method != "POST":
1✔
875
        return JsonResponse({"error": "POST required"}, status=405)
1✔
876

877
    model_path = request.POST.get("model_path")
1✔
878
    predict_path = request.POST.get("predict_path")
1✔
879
    raw_input = request.POST.get("input_data", "{}")
1✔
880

881
    try:
1✔
882
        input_data = json.loads(raw_input)
1✔
883
    except Exception:
1✔
884
        return JsonResponse({"error": "Invalid JSON in input_data"}, status=400)
1✔
885

886
    spec = importlib.util.spec_from_file_location("predict_module", predict_path)
×
887
    module = importlib.util.module_from_spec(spec)
×
888
    spec.loader.exec_module(module)
×
889

890
    if not hasattr(module, "predict"):
×
891
        return JsonResponse({"error": "predict() not found in predict.py"}, status=400)
×
892

893
    sig = inspect.signature(module.predict)
×
894
    num_params = len(sig.parameters)
×
895
    if num_params == 1:
×
896
        out = module.predict(input_data)
×
897
    else:
898
        out = module.predict(model_path, input_data)
×
899

900
    return JsonResponse({"output": out})
×
901

902

903
@login_required
1✔
904
def run_model_by_version_id(request, version_id):
1✔
905
    version = get_object_or_404(ModelVersion, id=version_id)
1✔
906

907
    if request.method != "POST":
1✔
908
        return JsonResponse({"error": "POST required"}, status=405)
1✔
909

910
    raw_input = request.POST.get("input_data", "{}")
1✔
911
    try:
1✔
912
        input_data = json.loads(raw_input)
1✔
913
    except Exception:
×
914
        return JsonResponse({"error": "Invalid JSON"}, status=400)
×
915

916
    result = test_model_on_cpu(version, input_data)
1✔
917
    return JsonResponse(result)
1✔
918

919

920
# ---------------------------------------------------------
921
# AI-ASSISTED MODEL INFO (ChatGPT integration)
922
# ---------------------------------------------------------
923
@login_required
1✔
924
@csrf_exempt
1✔
925
@require_POST
1✔
926
def generate_model_info(request):
1✔
927
    """
928
    Generate a 'Model Information' text using ONLY the three artifacts:
929
      - model_file (.pt)
930
      - predict_file (.py)
931
      - schema_file (.json)
932

933
    Modes:
934
      1) New upload (Add Version form)
935
         - uses request.FILES['model_file'], ['predict_file'], ['schema_file']
936
      2) Existing version (comments/detail screen)
937
         - POST includes 'version_id'; reads files from ModelVersion fields
938
    """
939

940
    # 1. OpenAI client
941
    api_key = getattr(settings, "OPENAI_API_KEY", None)
1✔
942
    if not api_key:
1✔
943
        return JsonResponse(
1✔
944
            {"error": "Server is missing OPENAI_API_KEY configuration."},
945
            status=500,
946
        )
947

948
    client = OpenAI(api_key=api_key)
1✔
949

950
    # 2. Collect artifacts (two modes)
951
    model_summary = ""
1✔
952
    predict_source = ""
1✔
953
    schema_text = ""
1✔
954

955
    version_id = request.POST.get("version_id")
1✔
956

957
    # ---------- Mode A: existing version (from DB) ----------
958
    if version_id:
1✔
959
        version = ModelVersion.objects.filter(id=version_id).first()
1✔
960
        if not version:
1✔
961
            return JsonResponse({"error": "Model version not found."}, status=404)
×
962

963
        model_path = version.model_file.path if version.model_file else None
1✔
964
        predict_path = version.predict_file.path if version.predict_file else None
1✔
965
        schema_path = version.schema_file.path if version.schema_file else None
1✔
966

967
        if not (model_path and predict_path and schema_path):
1✔
968
            return JsonResponse(
×
969
                {
970
                    "error": (
971
                        "This version is missing one or more artifacts. "
972
                        "Please re-upload the model, predict.py, and schema.json."
973
                    )
974
                },
975
                status=400,
976
            )
977

978
        # model.pt -> summarize (don't pass binary to the model)
979
        try:
1✔
980
            size_mb = os.path.getsize(model_path) / (1024 * 1024)
1✔
981
            model_summary = (
1✔
982
                f"PyTorch model weights file '{os.path.basename(model_path)}', "
983
                f"approx. {size_mb:.2f} MB."
984
            )
985
        except OSError:
×
986
            model_summary = "PyTorch model weights file (size unknown)."
×
987

988
        # predict.py (truncated)
989
        try:
1✔
990
            with open(predict_path, "r", encoding="utf-8", errors="ignore") as f:
1✔
991
                predict_source = f.read(4000)
1✔
992
        except OSError:
×
993
            predict_source = ""
×
994

995
        # schema.json (truncated)
996
        try:
1✔
997
            with open(schema_path, "r", encoding="utf-8", errors="ignore") as f:
1✔
998
                schema_text = f.read(4000)
1✔
999
        except OSError:
×
1000
            schema_text = ""
×
1001

1002
    # ---------- Mode B: Add Version form (new upload, from request.FILES) ----------
1003
    else:
1004
        # These are the field names on VersionForm/ModelVersion.
1005
        # The actual filenames are model.pt, predict.py, schema.json – that's fine.
1006
        model_file = request.FILES.get("model_file")
1✔
1007
        predict_file = request.FILES.get("predict_file")
1✔
1008
        schema_file = request.FILES.get("schema_file")
1✔
1009

1010
        if not (model_file and predict_file and schema_file):
1✔
1011
            return JsonResponse(
×
1012
                {
1013
                    "error": (
1014
                        "Could not find all required files. Please make sure you "
1015
                        "have uploaded a .pt, a .py, and a .json file."
1016
                    )
1017
                },
1018
                status=400,
1019
            )
1020

1021
        # model.pt summary (size only)
1022
        try:
1✔
1023
            size_mb = model_file.size / (1024 * 1024)
1✔
1024
        except Exception:
×
1025
            size_mb = 0.0
×
1026
        model_summary = f"PyTorch model file '{model_file.name}' (~{size_mb:.2f} MB)."
1✔
1027

1028
        # predict.py content (truncate and rewind so upload still works)
1029
        try:
1✔
1030
            predict_bytes = predict_file.read()
1✔
1031
            predict_source = predict_bytes.decode("utf-8", errors="ignore")[:4000]
1✔
1032
            predict_file.seek(0)
1✔
1033
        except Exception:
×
1034
            predict_source = ""
×
1035

1036
        # schema.json content (truncate and rewind)
1037
        try:
1✔
1038
            schema_bytes = schema_file.read()
1✔
1039
            schema_text = schema_bytes.decode("utf-8", errors="ignore")[:4000]
1✔
1040
            schema_file.seek(0)
1✔
1041
        except Exception:
×
1042
            schema_text = ""
×
1043

1044
    # Fallbacks if we couldn't read something
1045
    if not predict_source:
1✔
1046
        predict_source = "No usable predict.py content could be read."
×
1047
    if not schema_text:
1✔
1048
        schema_text = "No usable schema.json content could be read."
×
1049

1050
    # 3. Build prompt for OpenAI
1051
    system_prompt = (
1✔
1052
        "You are an ML engineer who writes concise release notes for model versions. "
1053
        "Write for engineers and product managers. Avoid lists; use 3–6 plain sentences."
1054
    )
1055

1056
    user_prompt = f"""You are given information about a model version from three artifacts.
1✔
1057

1058
PyTorch model summary:
1059
{model_summary}
1060

1061
predict.py (truncated):
1062
```python
1063
{predict_source}
1064
```
1065

1066
schema.json (truncated):
1067
```json
1068
{schema_text}
1069
```
1070

1071
Using only this information, write a description suitable for a Model Information field in a deployment dashboard.
1072

1073
Explain:
1074
- What the model does and the type of task it solves.
1075
- The kinds of inputs/outputs you can infer from the schema.
1076
- Any important assumptions, limitations, or warnings (e.g. domain coverage, language, bias, need for monitoring).
1077

1078
Write 3–6 sentences of neutral, professional prose.
1079
Do NOT include headings or bullet points.
1080
"""
1081

1082
    # 4. Call OpenAI
1083
    try:
1✔
1084
        completion = client.chat.completions.create(
1✔
1085
            model="gpt-4.1-mini",
1086
            messages=[
1087
                {"role": "system", "content": system_prompt},
1088
                {"role": "user", "content": user_prompt},
1089
            ],
1090
            temperature=0.4,
1091
            max_tokens=300,
1092
        )
1093
        description = completion.choices[0].message.content.strip()
1✔
1094
        return JsonResponse({"description": description})
1✔
1095

1096
    except Exception as e:
1✔
1097
        # Log error to server console for debugging
1098
        print("Error from OpenAI in generate_model_info:", repr(e))
1✔
1099
        return JsonResponse(
1✔
1100
            {
1101
                "error": (
1102
                    "Network or server issue while generating description. "
1103
                    "Please try again or write it manually."
1104
                )
1105
            },
1106
            status=500,
1107
        )
1108

1109

1110
# ---------------------------------------------------------
1111
# ADMIN STATS VIEW  ( /admin/stats/ )
1112
# ---------------------------------------------------------
1113
@staff_member_required
1✔
1114
def admin_stats(request):
1✔
1115
    # top counts
1116
    total_uploads = ModelUpload.objects.count()
1✔
1117
    total_versions = ModelVersion.objects.count()
1✔
1118
    total_users = User.objects.count()
1✔
1119

1120
    # version breakdown
1121
    active_versions = ModelVersion.objects.filter(
1✔
1122
        is_deleted=False, is_active=True, status="PASS"
1123
    ).count()
1124
    deleted_versions = ModelVersion.objects.filter(is_deleted=True).count()
1✔
1125
    inactive_versions = ModelVersion.objects.filter(
1✔
1126
        is_deleted=False, status="PASS", is_active=False
1127
    ).count()
1128

1129
    # superusers shown as admin in the pie chart
1130
    superusers_count = User.objects.filter(is_superuser=True).count()
1✔
1131

1132
    # roles for non-superusers
1133
    role_rows = list(
1✔
1134
        Profile.objects.filter(user__is_superuser=False)
1135
        .values("role")
1136
        .annotate(c=Count("id"))
1137
        .order_by("role")
1138
    )
1139

1140
    # users without profile (non-superuser)
1141
    no_profile_count = User.objects.filter(
1✔
1142
        profile__isnull=True, is_superuser=False
1143
    ).count()
1144
    if no_profile_count:
1✔
1145
        role_rows.append({"role": "no-profile", "c": no_profile_count})
×
1146

1147
    # add admin bucket
1148
    if superusers_count:
1✔
1149
        role_rows.append({"role": "admin", "c": superusers_count})
1✔
1150

1151
    # versions by status
1152
    status_rows = list(
1✔
1153
        ModelVersion.objects.values("status").annotate(c=Count("id")).order_by("status")
1154
    )
1155

1156
    # versions by category
1157
    category_rows = list(
1✔
1158
        ModelVersion.objects.values("category")
1159
        .annotate(c=Count("id"))
1160
        .order_by("category")
1161
    )
1162

1163
    # top uploaders
1164
    top_uploaders = list(
1✔
1165
        ModelUpload.objects.values("user__username")
1166
        .annotate(c=Count("id"))
1167
        .order_by("-c")[:10]
1168
    )
1169

1170
    context = {
1✔
1171
        "total_uploads": total_uploads,
1172
        "total_versions": total_versions,
1173
        "total_users": total_users,
1174
        "active_versions": active_versions,
1175
        "deleted_versions": deleted_versions,
1176
        "inactive_versions": inactive_versions,
1177
        "role_counts_json": json.dumps(role_rows),
1178
        "version_status_counts_json": json.dumps(status_rows),
1179
        "version_category_counts_json": json.dumps(category_rows),
1180
        "top_uploaders_json": json.dumps(top_uploaders),
1181
        "top_uploaders": top_uploaders,
1182
    }
1183
    return render(request, "note2webapp/admin_stats.html", context)
1✔
1184

1185

1186
# ---------------------------------------------------------
1187
# MODEL COMMENTS VIEW
1188
# ---------------------------------------------------------
1189
@login_required
1✔
1190
def model_comments_view(request, version_id):
1✔
1191
    """
1192
    Display comments thread for a specific model version.
1193
    Includes:
1194
    - Parent comments and their replies
1195
    - User reaction state (like/dislike)
1196
    - Author badges and role indicators
1197
    - Back button with return_to parameter support
1198
    """
1199
    version = get_object_or_404(ModelVersion, id=version_id)
1✔
1200

1201
    comments = (
1✔
1202
        ModelComment.objects.filter(model_version=version, parent__isnull=True)
1203
        .select_related("user__profile")
1204
        .prefetch_related("replies__user__profile", "reactions")
1205
    )
1206

1207
    # map: comment_id → 'like' or 'dislike' for the current user
1208
    if request.user.is_authenticated:
1✔
1209
        reactions = CommentReaction.objects.filter(
1✔
1210
            user=request.user,
1211
            comment__model_version=version,
1212
        )
1213
        user_reactions = {r.comment_id: r.reaction_type for r in reactions}
1✔
1214
    else:
NEW
1215
        user_reactions = {}
×
1216

1217
    # Back button target:
1218
    # Constructs proper URL based on user role and return_to parameter
1219
    default_back = reverse("test_model_cpu", args=[version.id])
1✔
1220
    return_to_param = request.GET.get("return_to")
1✔
1221
    model_id = request.GET.get("model_id", version.upload.id)
1✔
1222

1223
    # Build proper back URL based on return_to parameter
1224
    if return_to_param == "reviewer":
1✔
1225
        # Go to reviewer dashboard with detail page
NEW
1226
        return_to = f"{reverse('reviewer_dashboard')}?page=detail&pk={model_id}"
×
1227
    elif return_to_param == "uploader":
1✔
1228
        # Go to uploader version management
NEW
1229
        return_to = reverse("model_versions", args=[model_id])
×
1230
    elif return_to_param == "dashboard":
1✔
1231
        # Go to general dashboard
NEW
1232
        return_to = f"{reverse('dashboard')}?page=detail&pk={model_id}"
×
1233
    elif return_to_param and return_to_param.startswith("/"):
1✔
1234
        # Custom URL path
NEW
1235
        return_to = return_to_param
×
1236
    else:
1237
        # Default to test model page
1238
        return_to = default_back
1✔
1239

1240
    context = {
1✔
1241
        "version": version,
1242
        "comments": comments,
1243
        "user_reactions": user_reactions,
1244
        "is_uploader": request.user.is_authenticated
1245
        and request.user == version.upload.user,
1246
        "back_url": return_to,
1247
    }
1248
    return render(request, "note2webapp/model_comments.html", context)
1✔
1249

1250

1251
# ---------------------------------------------------------
1252
# TOGGLE COMMENT REACTION (LIKE/DISLIKE)
1253
# ---------------------------------------------------------
1254
@login_required
1✔
1255
@require_POST
1✔
1256
def toggle_comment_reaction(request, comment_id):
1✔
1257
    """
1258
    Toggle a user's reaction (like/dislike) on a comment or reply.
1259

1260
    Returns:
1261
    - 400 if user tries to react to their own comment
1262
    - 400 if reaction_type is invalid
1263
    - 200 with success=True if reaction was toggled/deleted
1264

1265
    Response includes:
1266
    - likes_count: current like count
1267
    - dislikes_count: current dislike count
1268
    - user_reaction: 'like', 'dislike', or None
1269
    """
NEW
1270
    comment = get_object_or_404(ModelComment, id=comment_id)
×
1271

1272
    # 🔒 Block reacting to your own comment (uploader OR reviewer)
1273
    # This is enforced on frontend AND backend for security
NEW
1274
    if comment.user_id == request.user.id:
×
NEW
1275
        return JsonResponse(
×
1276
            {
1277
                "success": False,
1278
                "error": "You can't like or dislike your own comment.",
1279
                "likes_count": comment.get_likes_count(),
1280
                "dislikes_count": comment.get_dislikes_count(),
1281
                "user_reaction": None,
1282
            },
1283
            status=400,
1284
        )
1285

NEW
1286
    reaction_type = request.POST.get("reaction_type")
×
NEW
1287
    if reaction_type not in ("like", "dislike"):
×
UNCOV
1288
        return JsonResponse(
×
1289
            {"success": False, "error": "Invalid reaction type."}, status=400
1290
        )
1291

1292
    # Toggle / switch the reaction
1293
    # If the user already has this reaction, delete it.
1294
    # If they have a different reaction, replace it.
1295
    # If they have no reaction, create it.
NEW
1296
    reaction, created = CommentReaction.objects.get_or_create(
×
1297
        user=request.user,
1298
        comment=comment,
1299
        defaults={"reaction_type": reaction_type},
1300
    )
1301

NEW
1302
    if not created:
×
NEW
1303
        if reaction.reaction_type == reaction_type:
×
1304
            # same reaction again → remove it (toggle off)
NEW
1305
            reaction.delete()
×
1306
        else:
1307
            # switch like ↔ dislike
NEW
1308
            reaction.reaction_type = reaction_type
×
NEW
1309
            reaction.save()
×
1310

NEW
1311
    likes = comment.get_likes_count()
×
NEW
1312
    dislikes = comment.get_dislikes_count()
×
1313

1314
    # this user's current reaction (if any)
NEW
1315
    try:
×
NEW
1316
        current = CommentReaction.objects.get(user=request.user, comment=comment)
×
NEW
1317
        user_reaction = current.reaction_type
×
NEW
1318
    except CommentReaction.DoesNotExist:
×
NEW
1319
        user_reaction = None
×
1320

NEW
1321
    return JsonResponse(
×
1322
        {
1323
            "success": True,
1324
            "likes_count": likes,
1325
            "dislikes_count": dislikes,
1326
            "user_reaction": user_reaction,
1327
        }
1328
    )
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