24
-    <div>
25
-      <label class="block text-sm font-medium mb-1">Role</label>
26
-      {{ form.role }}
27
-      {% if form.role.errors %}<p class="text-red-600 text-sm">{{ form.role.errors.0 }}</p>{% endif %}
16
+    {% if form.username %}{{ form.username|as_crispy_field }}{% endif %}
17
+    {% if form.email %}{{ form.email|as_crispy_field }}{% endif %}
18
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
19
+      <div>{% if form.first_name %}{{ form.first_name|as_crispy_field }}{% endif %}</div>
20
+      <div>{% if form.last_name %}{{ form.last_name|as_crispy_field }}{% endif %}</div>
28 21
     </div>
22
+    {% if form.role %}{{ form.role|as_crispy_field }}{% endif %}
29 23
     <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
30
-      <div>
31
-        <label class="block text-sm font-medium mb-1">Password</label>
32
-        <input type="password" name="password1" class="w-full border rounded px-3 py-2" required>
33
-        {% if form.password1.errors %}<p class="text-red-600 text-sm">{{ form.password1.errors.0 }}</p>{% endif %}
34
-      </div>
35
-      <div>
36
-        <label class="block text-sm font-medium mb-1">Confirm Password</label>
37
-        <input type="password" name="password2" class="w-full border rounded px-3 py-2" required>
38
-        {% if form.password2.errors %}<p class="text-red-600 text-sm">{{ form.password2.errors.0 }}</p>{% endif %}
39
-      </div>
24
+      <div>{% if form.password1 %}{{ form.password1|as_crispy_field }}{% endif %}</div>
25
+      <div>{% if form.password2 %}{{ form.password2|as_crispy_field }}{% endif %}</div>
40 26
     </div>
41 27
   {% else %}
42 28
     <div>
43 29
       <label class="block text-sm font-medium mb-1">Username</label>
44 30
       <input type="text" value="{{ item.username }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
45 31
     </div>
46
-    <div>
47
-      <label class="block text-sm font-medium mb-1">Email</label>
48
-      <input type="email" name="email" value="{{ form.email.value|default:'' }}" class="w-full border rounded px-3 py-2">
49
-      {% if form.email.errors %}<p class="text-red-600 text-sm">{{ form.email.errors.0 }}</p>{% endif %}
50
-    </div>
51
-    <div>
52
-      <label class="block text-sm font-medium mb-1">Role</label>
53
-      {{ form.role }}
54
-      {% if form.role.errors %}<p class="text-red-600 text-sm">{{ form.role.errors.0 }}</p>{% endif %}
32
+    {% if form.email %}{{ form.email|as_crispy_field }}{% endif %}
33
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
34
+      <div>{% if form.first_name %}{{ form.first_name|as_crispy_field }}{% endif %}</div>
35
+      <div>{% if form.last_name %}{{ form.last_name|as_crispy_field }}{% endif %}</div>
55 36
     </div>
37
+    {% if form.role %}{{ form.role|as_crispy_field }}{% endif %}
56 38
     <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
57
-      <div>
58
-        <label class="block text-sm font-medium mb-1">New Password (optional)</label>
59
-        <input type="password" name="password1" class="w-full border rounded px-3 py-2">
60
-      </div>
61
-      <div>
62
-        <label class="block text-sm font-medium mb-1">Confirm New Password</label>
63
-        <input type="password" name="password2" class="w-full border rounded px-3 py-2">
64
-      </div>
39
+      <div>{% if form.password1 %}{{ form.password1|as_crispy_field }}{% endif %}</div>
40
+      <div>{% if form.password2 %}{{ form.password2|as_crispy_field }}{% endif %}</div>
65 41
     </div>
66 42
   {% endif %}
67 43
 
@@ -71,4 +47,3 @@
71 47
   </div>
72 48
 </form>
73 49
 {% endblock %}
74
-

+ 10 - 4
recycle_core/templates/recycle_core/weigh_ticket_detail.html

@@ -5,16 +5,22 @@
5 5
 {% render_breadcrumbs breadcrumbs %}
6 6
 <div class="flex items-center justify-between mb-4">
7 7
   <h1 class="text-2xl font-semibold">Weigh Ticket {{ ticket.ticket_number|default:ticket.id }}</h1>
8
-  <form method="post" action="{{ generate_url }}">
9
-    {% csrf_token %}
10
-    <button class="px-3 py-2 rounded bg-blue-600 text-white">Generate Invoice</button>
11
-  </form>
8
+  <div class="flex items-center gap-2">
9
+    <a href="{% url 'recycle_core:pickup_detail' ticket.pickup.id %}" class="btn-outline">View Pickup</a>
10
+    {% if perms.recycle_core.generate_invoice %}
11
+    <form method="post" action="{{ generate_url }}">
12
+      {% csrf_token %}
13
+      <button class="px-3 py-2 rounded bg-blue-600 text-white">Generate Invoice</button>
14
+    </form>
15
+    {% endif %}
16
+  </div>
12 17
 </div>
13 18
 
14 19
 <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
15 20
   <div class="bg-white rounded shadow p-4">
16 21
     <h2 class="font-medium mb-2">Details</h2>
17 22
     <dl class="grid grid-cols-3 gap-2 text-sm">
23
+      <dt class="text-gray-500">Pickup</dt><dd class="col-span-2"><a class="text-blue-700 hover:underline" href="{% url 'recycle_core:pickup_detail' ticket.pickup.id %}">#{{ ticket.pickup.id }}</a></dd>
18 24
       <dt class="text-gray-500">Customer</dt><dd class="col-span-2">{{ ticket.pickup.customer.name }}</dd>
19 25
       <dt class="text-gray-500">Recorded</dt><dd class="col-span-2">{{ ticket.recorded_at }}</dd>
20 26
       <dt class="text-gray-500">Gross</dt><dd class="col-span-2">{{ ticket.gross_weight }} {{ ticket.unit }}</dd>

+ 17 - 0
recycle_core/templates/recycle_core/weigh_ticket_form.html

@@ -0,0 +1,17 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load crispy_forms_tags %}
3
+{% block title %}New Weigh Ticket{% endblock %}
4
+{% block content %}
5
+  <h1 class="text-2xl font-semibold mb-4">Create Weigh Ticket</h1>
6
+  <div class="bg-white rounded shadow p-4 max-w-2xl">
7
+    <form method="post">
8
+      {% csrf_token %}
9
+      {{ form|crispy }}
10
+      <div class="mt-4 flex items-center gap-2">
11
+        <button class="px-4 py-2 bg-blue-600 text-white rounded">Save</button>
12
+        <a class="btn-outline" href="{% url 'recycle_core:pickups_list' %}">Cancel</a>
13
+      </div>
14
+    </form>
15
+  </div>
16
+{% endblock %}
17
+

+ 2 - 0
recycle_core/templates/recycle_core/weigh_tickets_list.html

@@ -39,10 +39,12 @@
39 39
         <td class="px-4 py-2">{{ t.recorded_at }}</td>
40 40
         <td class="px-4 py-2">{{ t.net_weight }} {{ t.unit }}</td>
41 41
         <td class="px-4 py-2">
42
+          {% if perms.recycle_core.generate_invoice %}
42 43
           <form method="post" action="{% url 'recycle_core:weigh_ticket_generate_invoice' t.id %}">
43 44
             {% csrf_token %}
44 45
             <button class="btn-outline">Generate Invoice</button>
45 46
           </form>
47
+          {% endif %}
46 48
         </td>
47 49
       </tr>
48 50
       {% empty %}

+ 4 - 4
recycle_core/urls.py

@@ -11,6 +11,7 @@ urlpatterns = [
11 11
     path("org-users/<int:pk>/delete/", views.org_user_delete, name="org_user_delete"),
12 12
     path("materials/", views.materials_list, name="materials_list"),
13 13
     path("materials/<int:pk>/edit/", views.material_edit, name="material_edit"),
14
+    path("materials/<int:pk>/images/reorder/", views.material_images_reorder, name="material_images_reorder"),
14 15
     path("materials/<int:pk>/delete/", views.material_delete, name="material_delete"),
15 16
 
16 17
     path("customers/", views.customers_list, name="customers_list"),
@@ -21,6 +22,8 @@ urlpatterns = [
21 22
     path("pickups/<int:pk>/assign/", views.pickup_assign, name="pickup_assign"),
22 23
     path("pickups/<int:pk>/set-status/", views.pickup_set_status, name="pickup_set_status"),
23 24
     path("pickups/<int:pk>/generate-invoice/", views.pickup_generate_invoice, name="pickup_generate_invoice"),
25
+    path("pickups/<int:pk>/create-weigh-ticket/", views.pickup_create_weigh_ticket, name="pickup_create_weigh_ticket"),
26
+    path("pickups/<int:pk>/", views.pickup_detail, name="pickup_detail"),
24 27
 
25 28
     path("scrap-listings/", views.scrap_listings_list, name="scrap_listings_list"),
26 29
     path("scrap-listings/<int:pk>/open/", views.scrap_listing_open, name="scrap_listing_open"),
@@ -31,18 +34,15 @@ urlpatterns = [
31 34
     path("weigh-tickets/<int:pk>/", views.weigh_ticket_detail, name="weigh_ticket_detail"),
32 35
     path("weigh-tickets/<int:pk>/generate-invoice/", views.weigh_ticket_generate_invoice, name="weigh_ticket_generate_invoice"),
33 36
 
34
-    path("invoices/", views.invoices_list, name="invoices_list"),
35
-    path("invoices/<int:pk>/add-payment/", views.invoice_add_payment, name="invoice_add_payment"),
36 37
 
37 38
     path("documents/", views.documents_list, name="documents_list"),
38 39
     path("audit-logs/", views.audit_logs_list, name="audit_logs_list"),
39 40
     path("audit-logs/export/", views.audit_logs_export_csv, name="audit_logs_export_csv"),
40
-    path("payouts/", views.payouts_list, name="payouts_list"),
41
-    path("payouts/export/", views.payouts_export_csv, name="payouts_export_csv"),
42 41
     # Services
43 42
     path("services/", views.services_list, name="services_list"),
44 43
     path("services/new/", views.service_create, name="service_create"),
45 44
     path("services/<int:pk>/edit/", views.service_edit, name="service_edit"),
46 45
     path("services/<int:pk>/delete/", views.service_delete, name="service_delete"),
47 46
     path("services/<int:pk>/toggle-enabled/", views.service_toggle_enabled, name="service_toggle_enabled"),
47
+    path("services/reorder/", views.services_reorder, name="services_reorder"),
48 48
 ]

+ 141 - 164
recycle_core/views.py

@@ -9,11 +9,13 @@ import django_filters as filters
9 9
 from admin_frontend.templatetags.public_urls import public_route
10 10
 from admin_frontend.nav import _nav_items
11 11
 from cms.views import breadcrumbs
12
+from orgs.decorators import permissions_required
12 13
 
13 14
 from orgs.models import Organization, UserProfile
14 15
 from .models import (
15 16
     MaterialCategory,
16 17
     Material,
18
+    MaterialImage,
17 19
     ProvidedService,
18 20
     Customer,
19 21
     CustomerSite,
@@ -22,11 +24,8 @@ from .models import (
22 24
     WeighLine,
23 25
     ScrapListing,
24 26
     ScrapBid,
25
-    Invoice,
26
-    Payment,
27 27
     Document,
28 28
     AuditLog,
29
-    Payout,
30 29
 )
31 30
 from .forms import (
32 31
     MaterialForm,
@@ -34,14 +33,16 @@ from .forms import (
34 33
     CustomerForm,
35 34
     PickupAssignForm,
36 35
     PickupStatusForm,
37
-    PaymentForm,
38 36
     DocumentForm,
39 37
     ProvidedServiceForm,
38
+    WeighTicketForm,
40 39
 )
41 40
 from .services.billing import generate_invoice_for_pickup
42 41
 from django.contrib.contenttypes.models import ContentType
43 42
 from django.contrib.auth import get_user_model
44
-from django.http import HttpResponse
43
+from django.http import HttpResponse, JsonResponse
44
+from django.db import transaction
45
+import json
45 46
 import csv
46 47
 from django.db.models import Sum, F, ExpressionWrapper, DecimalField, Value
47 48
 from django.db.models.functions import Coalesce
@@ -186,12 +187,20 @@ def org_user_create(request):
186 187
     if request.method == "POST":
187 188
         if form.is_valid():
188 189
             User = get_user_model()
189
-            user = User.objects.create(username=form.cleaned_data["username"], email=form.cleaned_data.get("email") or "")
190
+            user = User.objects.create(
191
+                username=form.cleaned_data["username"],
192
+                email=form.cleaned_data.get("email") or "",
193
+                first_name=form.cleaned_data.get("first_name") or "",
194
+                last_name=form.cleaned_data.get("last_name") or "",
195
+            )
190 196
             user.set_password(form.cleaned_data["password1"])
191 197
             user.save()
192 198
             org = getattr(request, "org", None)
193 199
             UserProfile.objects.create(user=user, organization=org, role=form.cleaned_data["role"])  # type: ignore[arg-type]
194 200
             messages.success(request, f"User '{user.username}' created.")
201
+            next_url = request.POST.get("next") or request.GET.get("next")
202
+            if next_url:
203
+                return redirect(next_url)
195 204
             return redirect("recycle_core:org_users_list")
196 205
         messages.error(request, "Please correct the errors below.")
197 206
     return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "creating": True, "cancel_url": reverse("recycle_core:org_users_list")})
@@ -210,11 +219,15 @@ def org_user_edit(request, pk: int):
210 219
     profile = getattr(user, "recycle_profile", None)
211 220
     form = UserEditForm(request.POST or None, initial={
212 221
         "email": user.email,
222
+        "first_name": user.first_name,
223
+        "last_name": user.last_name,
213 224
         "role": getattr(profile, "role", UserProfile.ROLE_MANAGER),
214 225
     })
215 226
     if request.method == "POST":
216 227
         if form.is_valid():
217 228
             user.email = form.cleaned_data.get("email") or ""
229
+            user.first_name = form.cleaned_data.get("first_name") or ""
230
+            user.last_name = form.cleaned_data.get("last_name") or ""
218 231
             p1 = form.cleaned_data.get("password1")
219 232
             if p1:
220 233
                 user.set_password(p1)
@@ -226,6 +239,9 @@ def org_user_edit(request, pk: int):
226 239
                 profile.organization = org
227 240
             profile.save()
228 241
             messages.success(request, f"User '{user.username}' updated.")
242
+            next_url = request.POST.get("next") or request.GET.get("next")
243
+            if next_url:
244
+                return redirect(next_url)
229 245
             return redirect("recycle_core:org_users_list")
230 246
         messages.error(request, "Please correct the errors below.")
231 247
     return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
@@ -277,6 +293,36 @@ def material_delete(request, pk: int):
277 293
 
278 294
 
279 295
 @staff_member_required
296
+@require_POST
297
+def material_images_reorder(request, pk: int):
298
+    """Reorder MaterialImage.display_order for a material.
299
+
300
+    Expects JSON {"ids": [image_id, ...]} in the new order.
301
+    """
302
+    material = get_object_or_404(Material, pk=pk)
303
+    try:
304
+        payload = json.loads(request.body.decode("utf-8"))
305
+        ids = payload.get("ids", [])
306
+        if not isinstance(ids, list):
307
+            return JsonResponse({"ok": False, "error": "Invalid payload"}, status=400)
308
+    except Exception:
309
+        return JsonResponse({"ok": False, "error": "Malformed JSON"}, status=400)
310
+
311
+    imgs = MaterialImage.objects.filter(material=material, id__in=ids)
312
+    img_map = {im.id: im for im in imgs}
313
+    with transaction.atomic():
314
+        for idx, iid in enumerate(ids):
315
+            im = img_map.get(iid)
316
+            if not im:
317
+                continue
318
+            if im.display_order != idx:
319
+                im.display_order = idx
320
+                im.save(update_fields=["display_order"])
321
+
322
+    return JsonResponse({"ok": True})
323
+
324
+
325
+@staff_member_required
280 326
 @public_route(label="Customers", order=20, icon="building-storefront")
281 327
 @breadcrumbs(label="Customers", name="re_customers")
282 328
 def customers_list(request):
@@ -416,6 +462,7 @@ def pickups_list(request):
416 462
 
417 463
 
418 464
 @staff_member_required
465
+@permissions_required('recycle_core.assign_driver', message="You are not allowed to assign drivers for this pickup.")
419 466
 @require_POST
420 467
 def pickup_assign(request, pk: int):
421 468
     pickup = get_object_or_404(PickupOrder, pk=pk)
@@ -431,6 +478,7 @@ def pickup_assign(request, pk: int):
431 478
 
432 479
 
433 480
 @staff_member_required
481
+@permissions_required('recycle_core.set_pickup_status', message="You are not allowed to change status for this pickup.")
434 482
 @require_POST
435 483
 def pickup_set_status(request, pk: int):
436 484
     pickup = get_object_or_404(PickupOrder, pk=pk)
@@ -445,6 +493,58 @@ def pickup_set_status(request, pk: int):
445 493
 
446 494
 
447 495
 @staff_member_required
496
+@breadcrumbs(label="Pickup Detail", parent="re_pickups")
497
+def pickup_detail(request, pk: int):
498
+    pickup = get_object_or_404(
499
+        PickupOrder.objects.select_related("organization", "customer", "site", "assigned_driver"), pk=pk
500
+    )
501
+    items = pickup.items.select_related("material").all()
502
+    context = {
503
+        "nav": _nav_items(),
504
+        "pickup": pickup,
505
+        "items": items,
506
+    }
507
+    return render(request, "recycle_core/pickup_detail.html", context)
508
+
509
+
510
+@staff_member_required
511
+@permissions_required('recycle_core.create_weigh_ticket', message="You are not allowed to create weigh tickets.")
512
+def pickup_create_weigh_ticket(request, pk: int):
513
+    pickup = get_object_or_404(PickupOrder.objects.select_related("customer", "organization"), pk=pk)
514
+    if hasattr(pickup, "weigh_ticket"):
515
+        messages.info(request, "Weigh ticket already exists for this pickup.")
516
+        return redirect("recycle_core:weigh_ticket_detail", pk=pickup.weigh_ticket.id)
517
+
518
+    if request.method == "POST":
519
+        form = WeighTicketForm(request.POST)
520
+        if form.is_valid():
521
+            wt = WeighTicket.objects.create(
522
+                pickup=pickup,
523
+                ticket_number=form.cleaned_data.get("ticket_number") or "",
524
+                gross_weight=form.cleaned_data["gross_weight"],
525
+                tare_weight=form.cleaned_data["tare_weight"],
526
+                net_weight=form.cleaned_data["net_weight"],
527
+                unit=form.cleaned_data["unit"],
528
+                recorded_by=request.user,
529
+            )
530
+            pickup.status = PickupOrder.STATUS_WEIGHED
531
+            pickup.save(update_fields=["status"])
532
+            messages.success(request, f"Weigh ticket {wt.ticket_number or wt.id} created.")
533
+            return redirect("recycle_core:weigh_ticket_detail", pk=wt.id)
534
+        messages.error(request, "Please correct the form errors.")
535
+    else:
536
+        form = WeighTicketForm()
537
+
538
+    context = {
539
+        "nav": _nav_items(),
540
+        "pickup": pickup,
541
+        "form": form,
542
+    }
543
+    return render(request, "recycle_core/weigh_ticket_form.html", context)
544
+
545
+
546
+@staff_member_required
547
+@permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
448 548
 @require_POST
449 549
 def pickup_generate_invoice(request, pk: int):
450 550
     pickup = get_object_or_404(PickupOrder, pk=pk)
@@ -619,6 +719,7 @@ def weigh_ticket_detail(request, pk: int):
619 719
 
620 720
 
621 721
 @staff_member_required
722
+@permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
622 723
 @require_POST
623 724
 def weigh_ticket_generate_invoice(request, pk: int):
624 725
     ticket = get_object_or_404(WeighTicket, pk=pk)
@@ -630,164 +731,7 @@ def weigh_ticket_generate_invoice(request, pk: int):
630 731
     return redirect("recycle_core:weigh_tickets_list")
631 732
 
632 733
 
633
-# Invoices UI ---------------------------------------------------------------
634
-
635
-@staff_member_required
636
-@public_route(label="Invoices", order=50, icon="receipt-percent")
637
-@breadcrumbs(label="Invoices", name="re_invoices")
638
-def invoices_list(request):
639
-    class InvoiceFilter(filters.FilterSet):
640
-        organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
641
-        customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
642
-        status = filters.ChoiceFilter(field_name="status", choices=Invoice.STATUS_CHOICES)
643
-        issued_at = filters.DateFromToRangeFilter(field_name="issued_at", label="Issued between")
644
-
645
-        class Meta:
646
-            model = Invoice
647
-            fields = ["organization", "customer", "status", "issued_at"]
648
-
649
-    base_qs = (
650
-        Invoice.objects.select_related("organization", "customer")
651
-        .annotate(
652
-            payments_total=Coalesce(
653
-                Sum("payments__amount"),
654
-                Value(Decimal("0.00")),
655
-                output_field=DecimalField(max_digits=14, decimal_places=2),
656
-            ),
657
-        )
658
-        .annotate(
659
-            balance=ExpressionWrapper(
660
-                F("total_amount") - F("payments_total"),
661
-                output_field=DecimalField(max_digits=14, decimal_places=2),
662
-            )
663
-        )
664
-        .order_by("-issued_at", "-id")
665
-    )
666
-    i_filter = InvoiceFilter(request.GET, queryset=base_qs)
667
-    qs = i_filter.qs
668
-    org = getattr(request, "org", None)
669
-    if org is not None:
670
-        qs = qs.filter(organization=org)
671
-
672
-    paginator = Paginator(qs, 10)
673
-    page = request.GET.get("page")
674
-    try:
675
-        page_obj = paginator.page(page)
676
-    except PageNotAnInteger:
677
-        page_obj = paginator.page(1)
678
-    except EmptyPage:
679
-        page_obj = paginator.page(paginator.num_pages)
680
-
681
-    pay_form = PaymentForm()
682
-    context = {
683
-        "nav": _nav_items(),
684
-        "invoices": page_obj.object_list,
685
-        "page_obj": page_obj,
686
-        "filter": i_filter,
687
-        "pay_form": pay_form,
688
-    }
689
-    return render(request, "recycle_core/invoices_list.html", context)
690
-
691
-
692
-@staff_member_required
693
-@require_POST
694
-def invoice_add_payment(request, pk: int):
695
-    invoice = get_object_or_404(Invoice, pk=pk)
696
-    form = PaymentForm(request.POST)
697
-    if form.is_valid():
698
-        Payment.objects.create(
699
-            invoice=invoice,
700
-            amount=form.cleaned_data["amount"],
701
-            currency_code=invoice.currency_code,
702
-            received_at=form.cleaned_data.get("received_at") or None,
703
-            reference=form.cleaned_data.get("reference", ""),
704
-        )
705
-        messages.success(request, f"Payment recorded for invoice #{invoice.id}.")
706
-    else:
707
-        messages.error(request, "Invalid payment data.")
708
-    return redirect("recycle_core:invoices_list")
709
-
710
-
711
-# Payouts UI + CSV ----------------------------------------------------------
712
-
713
-@staff_member_required
714
-@public_route(label="Payouts", order=52, icon="banknotes")
715
-@breadcrumbs(label="Payouts", name="re_payouts")
716
-def payouts_list(request):
717
-    class PayoutFilter(filters.FilterSet):
718
-        organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
719
-        customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
720
-        paid_at = filters.DateFromToRangeFilter(field_name="paid_at", label="Paid between")
721
-
722
-        class Meta:
723
-            model = Payout
724
-            fields = ["organization", "customer", "paid_at"]
725
-
726
-    base_qs = Payout.objects.select_related("organization", "customer").order_by("-paid_at", "-id")
727
-    p_filter = PayoutFilter(request.GET, queryset=base_qs)
728
-    qs = p_filter.qs
729
-    org = getattr(request, "org", None)
730
-    if org is not None:
731
-        qs = qs.filter(organization=org)
732
-
733
-    paginator = Paginator(qs, 10)
734
-    page = request.GET.get("page")
735
-    try:
736
-        page_obj = paginator.page(page)
737
-    except PageNotAnInteger:
738
-        page_obj = paginator.page(1)
739
-    except EmptyPage:
740
-        page_obj = paginator.page(paginator.num_pages)
741
-
742
-    context = {
743
-        "nav": _nav_items(),
744
-        "payouts": page_obj.object_list,
745
-        "page_obj": page_obj,
746
-        "filter": p_filter,
747
-    }
748
-    return render(request, "recycle_core/payouts_list.html", context)
749
-
750
-
751
-@staff_member_required
752
-def payouts_export_csv(request):
753
-    qs = Payout.objects.select_related("organization", "customer").all()
754
-    org_obj = getattr(request, "org", None)
755
-    if org_obj is not None:
756
-        qs = qs.filter(organization=org_obj)
757
-    org = request.GET.get("organization")
758
-    cust = request.GET.get("customer")
759
-    start = request.GET.get("paid_at_after") or request.GET.get("paid_at_min")
760
-    end = request.GET.get("paid_at_before") or request.GET.get("paid_at_max")
761
-    from django.utils.dateparse import parse_datetime
762
-
763
-    if org:
764
-        qs = qs.filter(organization_id=org)
765
-    if cust:
766
-        qs = qs.filter(customer_id=cust)
767
-    if start:
768
-        dt = parse_datetime(start)
769
-        if dt:
770
-            qs = qs.filter(paid_at__gte=dt)
771
-    if end:
772
-        dt = parse_datetime(end)
773
-        if dt:
774
-            qs = qs.filter(paid_at__lte=dt)
775
-
776
-    response = HttpResponse(content_type="text/csv")
777
-    response["Content-Disposition"] = "attachment; filename=payouts.csv"
778
-    writer = csv.writer(response)
779
-    writer.writerow(["organization", "customer", "amount", "currency", "paid_at", "reference", "pickup_id"]) 
780
-    for p in qs.iterator():
781
-        writer.writerow([
782
-            p.organization.code,
783
-            p.customer.name,
784
-            p.amount,
785
-            p.currency_code,
786
-            p.paid_at.isoformat(),
787
-            p.reference,
788
-            p.pickup_id or "",
789
-        ])
790
-    return response
734
+# Billing-related views moved to billing/views.py
791 735
 
792 736
 
793 737
 # Documents UI --------------------------------------------------------------
@@ -1024,3 +968,36 @@ def service_toggle_enabled(request, pk: int):
1024 968
         item.save(update_fields=["is_enabled"])
1025 969
         messages.success(request, f"Service '{item.title}' {'enabled' if item.is_enabled else 'disabled'}.")
1026 970
     return redirect("recycle_core:services_list")
971
+
972
+
973
+@require_POST
974
+@owner_required
975
+def services_reorder(request):
976
+    """Reorder ProvidedService.display_order for the current organization.
977
+
978
+    Expects JSON body with {"ids": [<service_id>, ...]} in the new order (top→bottom).
979
+    """
980
+    try:
981
+        data = json.loads(request.body.decode("utf-8"))
982
+        ids = data.get("ids", [])
983
+        if not isinstance(ids, list):
984
+            return JsonResponse({"ok": False, "error": "Invalid payload."}, status=400)
985
+    except Exception:
986
+        return JsonResponse({"ok": False, "error": "Malformed JSON."}, status=400)
987
+
988
+    org = getattr(request, "org", None)
989
+    # Fetch only services belonging to this org and requested ids
990
+    qs = ProvidedService.objects.filter(organization=org, id__in=ids)
991
+    existing = {obj.id: obj for obj in qs}
992
+
993
+    # Enforce order based on the incoming list; skip unknown ids
994
+    with transaction.atomic():
995
+        for idx, sid in enumerate(ids):
996
+            obj = existing.get(sid)
997
+            if not obj:
998
+                continue
999
+            if obj.display_order != idx:
1000
+                obj.display_order = idx
1001
+                obj.save(update_fields=["display_order"])
1002
+
1003
+    return JsonResponse({"ok": True})

+ 1 - 4
recycle_core/views_api.py

@@ -22,10 +22,6 @@ from .models import (
22 22
     PickupItem,
23 23
     WeighTicket,
24 24
     WeighLine,
25
-    Invoice,
26
-    InvoiceLine,
27
-    Payment,
28
-    Payout,
29 25
     ScrapListing,
30 26
     ScrapListingItem,
31 27
     ScrapBid,
@@ -34,6 +30,7 @@ from .models import (
34 30
     Document,
35 31
     AuditLog,
36 32
 )
33
+from billing.models import Invoice, InvoiceLine, Payment, Payout
37 34
 from .serializers import (
38 35
     OrganizationSerializer,
39 36
     MaterialCategorySerializer,

tum/soc - Gogs: Simplico Git Service

Açıklama Yok

tests_rest_tasks.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. # IRIS Source Code
  2. # Copyright (C) 2023 - DFIR-IRIS
  3. # contact@dfir-iris.org
  4. #
  5. # This program is free software; you can redistribute it and/or
  6. # modify it under the terms of the GNU Lesser General Public
  7. # License as published by the Free Software Foundation; either
  8. # version 3 of the License, or (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  13. # Lesser General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU Lesser General Public License
  16. # along with this program; if not, write to the Free Software Foundation,
  17. # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. from unittest import TestCase
  19. from iris import Iris
  20. _IDENTIFIER_FOR_NONEXISTENT_OBJECT = 123456789
  21. class TestsRestTasks(TestCase):
  22. def setUp(self) -> None:
  23. self._subject = Iris()
  24. def tearDown(self):
  25. self._subject.clear_database()
  26. def test_add_task_should_return_201(self):
  27. case_identifier = self._subject.create_dummy_case()
  28. body = {'task_assignees_id': [], 'task_description': '', 'task_status_id': 1, 'task_tags': '',
  29. 'task_title': 'dummy title', 'custom_attributes': {}}
  30. response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body)
  31. self.assertEqual(201, response.status_code)
  32. def test_add_task_with_missing_task_title_identifier_should_return_400(self):
  33. case_identifier = self._subject.create_dummy_case()
  34. body = {'task_assignees_id': [], 'task_description': '', 'task_status_id': 1, 'task_tags': '',
  35. 'custom_attributes': {}}
  36. response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body)
  37. self.assertEqual(400, response.status_code)
  38. def test_create_case_with_spurious_slash_should_return_404(self):
  39. case_identifier = self._subject.create_dummy_case()
  40. body = {'task_assignees_id': [], 'task_description': '', 'task_status_id': 1, 'task_tags': '',
  41. 'task_title': 'dummy title', 'custom_attributes': {}}
  42. response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks/', body)
  43. self.assertEqual(404, response.status_code)
  44. def test_get_task_should_return_200(self):
  45. case_identifier = self._subject.create_dummy_case()
  46. body = {'task_assignees_id': [], 'task_description': '', 'task_status_id': 1, 'task_tags': '',
  47. 'task_title': 'dummy title',
  48. 'custom_attributes': {}}
  49. response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  50. task_identifier = response['id']
  51. response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks/{task_identifier}')
  52. self.assertEqual(200, response.status_code)
  53. def test_get_task_with_missing_task_identifier_should_return_error(self):
  54. case_identifier = self._subject.create_dummy_case()
  55. body = {'task_assignees_id': [], 'task_description': '', 'task_status_id': 1, 'task_tags': '',
  56. 'task_title': 'dummy title', 'custom_attributes': {}}
  57. self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body)
  58. response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}')
  59. self.assertEqual(404, response.status_code)
  60. def test_delete_task_should_return_204(self):
  61. case_identifier = self._subject.create_dummy_case()
  62. body = {'task_assignees_id': [], 'task_description': '', 'task_status_id': 1, 'task_tags': '',
  63. 'task_title': 'dummy title',
  64. 'custom_attributes': {}}
  65. response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  66. task_identifier = response['id']
  67. test = self._subject.delete(f'/api/v2/cases/{case_identifier}/tasks/{task_identifier}')
  68. self.assertEqual(204, test.status_code)
  69. def test_delete_task_with_missing_task_identifier_should_return_404(self):
  70. case_identifier = self._subject.create_dummy_case()
  71. body = {'task_assignees_id': [], 'task_description': '', 'task_status_id': 1, 'task_tags': '', 'task_title': 'dummy title',
  72. 'custom_attributes': {}}
  73. self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body)
  74. test = self._subject.delete(f'/api/v2/cases/{case_identifier}/tasks/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}')
  75. self.assertEqual(404, test.status_code)
  76. def test_get_user_task_should_not_fail(self):
  77. case_identifier = self._subject.create_dummy_case()
  78. user = self._subject.create_dummy_user()
  79. body = {'task_assignees_id': [user.get_identifier()], 'task_description': '', 'task_status_id': 1, 'task_tags': '', 'task_title': 'dummy title',
  80. 'custom_attributes': {}}
  81. self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body)
  82. response = user.get('/user/tasks/list')
  83. self.assertEqual(200, response.status_code)
  84. def test_get_user_task_should_contain_task_case_field(self):
  85. case_identifier = self._subject.create_dummy_case()
  86. user = self._subject.create_dummy_user()
  87. body = {'task_assignees_id': [user.get_identifier()], 'task_description': '', 'task_status_id': 1, 'task_tags': '', 'task_title': 'dummy title',
  88. 'custom_attributes': {}}
  89. self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body)
  90. response = user.get('/user/tasks/list').json()
  91. self.assertEqual(f'#{case_identifier} - case name', response['data']['tasks'][0]['task_case'])
  92. def test_update_task_should_not_fail(self):
  93. case_identifier = self._subject.create_dummy_case()
  94. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'dummy title'}
  95. response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  96. identifier = response['id']
  97. response = self._subject.update(f'/api/v2/cases/{case_identifier}/tasks/{identifier}',
  98. {'task_title': 'new title', 'task_status_id': 1, 'task_assignees_id': []})
  99. self.assertEqual(200, response.status_code)
  100. def test_update_task_should_update_assignees(self):
  101. case_identifier = self._subject.create_dummy_case()
  102. user = self._subject.create_dummy_user()
  103. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'dummy title'}
  104. response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  105. identifier = response['id']
  106. self._subject.update(f'/api/v2/cases/{case_identifier}/tasks/{identifier}',
  107. {'task_title': 'dummy title', 'task_status_id': 1, 'task_assignees_id': [user.get_identifier()]})
  108. response = self._subject.get('/case/tasks/list', query_parameters={'cid': case_identifier}).json()
  109. self.assertEqual(user.get_identifier(), response['data']['tasks'][0]['task_assignees'][0]['id'])
  110. def test_update_task_without_task_status_id_should_return_400(self):
  111. case_identifier = self._subject.create_dummy_case()
  112. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'dummy title'}
  113. response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  114. identifier = response['id']
  115. response = self._subject.update(f'/api/v2/cases/{case_identifier}/tasks/{identifier}',
  116. {'task_title': 'new title', 'task_assignees_id': []})
  117. self.assertEqual(400, response.status_code)
  118. def test_update_task_should_return_a_task(self):
  119. case_identifier = self._subject.create_dummy_case()
  120. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'dummy title'}
  121. response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  122. identifier = response['id']
  123. response = self._subject.update(f'/api/v2/cases/{case_identifier}/tasks/{identifier}',
  124. {'task_title': 'new title', 'task_status_id': 1, 'task_assignees_id': []}).json()
  125. self.assertEqual('new title', response['task_title'])
  126. def test_get_tasks_should_return_200(self):
  127. case_identifier = self._subject.create_dummy_case()
  128. response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks')
  129. self.assertEqual(200, response.status_code)
  130. def test_get_tasks_should_return_empty_list_for_field_data_when_there_are_no_tasks(self):
  131. case_identifier = self._subject.create_dummy_case()
  132. response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks').json()
  133. self.assertEqual([], response['data'])
  134. def test_get_tasks_should_return_total(self):
  135. case_identifier = self._subject.create_dummy_case()
  136. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'dummy title'}
  137. self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  138. response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks').json()
  139. self.assertEqual(1, response['total'])
  140. def test_get_tasks_should_honour_per_page_pagination_parameter(self):
  141. case_identifier = self._subject.create_dummy_case()
  142. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'task1'}
  143. self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  144. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'task2'}
  145. self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  146. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'task3'}
  147. self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  148. response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks', { 'per_page': 2 }).json()
  149. self.assertEqual(2, len(response['data']))
  150. def test_get_tasks_should_return_current_page(self):
  151. case_identifier = self._subject.create_dummy_case()
  152. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'task1'}
  153. self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  154. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'task2'}
  155. self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  156. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'task3'}
  157. self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  158. response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks', { 'page': 2, 'per_page': 2 }).json()
  159. self.assertEqual(2, response['current_page'])
  160. def test_get_tasks_should_return_correct_task_uuid(self):
  161. case_identifier = self._subject.create_dummy_case()
  162. body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'title'}
  163. response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()
  164. identifier = response['id']
  165. response = self._subject.get(f'/api/v2/tasks/{identifier}').json()
  166. expected_uuid = response['task_uuid']
  167. response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks').json()
  168. self.assertEqual(expected_uuid, response['data'][0]['task_uuid'])