+  </TABLE>
466
+  >]
467
+  recycle_core_models_PickupOrder -> django_contrib_auth_models_User
468
+  [label=" created_by (created_pickups)"] [arrowhead=none, arrowtail=dot, dir=both];
469
+
470
+  recycle_core_models_PickupOrder -> recycle_core_models_TimestampedModel
471
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
472
+
473
+  recycle_core_models_PickupItem -> recycle_core_models_PickupOrder
474
+  [label=" pickup (items)"] [arrowhead=none, arrowtail=dot, dir=both];
475
+
476
+  recycle_core_models_PickupItem -> recycle_core_models_Material
477
+  [label=" material (pickupitem)"] [arrowhead=none, arrowtail=dot, dir=both];
478
+
479
+  recycle_core_models_PickupItem -> recycle_core_models_TimestampedModel
480
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
481
+
482
+  recycle_core_models_WeighTicket -> recycle_core_models_PickupOrder
483
+  [label=" pickup (weigh_ticket)"] [arrowhead=none, arrowtail=none, dir=both];
484
+  django_contrib_auth_models_User [label=<
485
+  <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
486
+  <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
487
+  <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
488
+  </TD></TR>
489
+  </TABLE>
490
+  >]
491
+  recycle_core_models_WeighTicket -> django_contrib_auth_models_User
492
+  [label=" recorded_by (weigh_tickets)"] [arrowhead=none, arrowtail=dot, dir=both];
493
+
494
+  recycle_core_models_WeighTicket -> recycle_core_models_TimestampedModel
495
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
496
+
497
+  recycle_core_models_WeighLine -> recycle_core_models_WeighTicket
498
+  [label=" ticket (lines)"] [arrowhead=none, arrowtail=dot, dir=both];
499
+
500
+  recycle_core_models_WeighLine -> recycle_core_models_Material
501
+  [label=" material (weighline)"] [arrowhead=none, arrowtail=dot, dir=both];
502
+
503
+  recycle_core_models_WeighLine -> recycle_core_models_TimestampedModel
504
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
505
+
506
+  recycle_core_models_Invoice -> orgs_models_Organization
507
+  [label=" organization (invoices)"] [arrowhead=none, arrowtail=dot, dir=both];
508
+
509
+  recycle_core_models_Invoice -> recycle_core_models_Customer
510
+  [label=" customer (invoices)"] [arrowhead=none, arrowtail=dot, dir=both];
511
+
512
+  recycle_core_models_Invoice -> recycle_core_models_PickupOrder
513
+  [label=" pickup (invoices)"] [arrowhead=none, arrowtail=dot, dir=both];
514
+
515
+  recycle_core_models_Invoice -> recycle_core_models_TimestampedModel
516
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
517
+
518
+  recycle_core_models_InvoiceLine -> recycle_core_models_Invoice
519
+  [label=" invoice (lines)"] [arrowhead=none, arrowtail=dot, dir=both];
520
+
521
+  recycle_core_models_InvoiceLine -> recycle_core_models_Material
522
+  [label=" material (invoiceline)"] [arrowhead=none, arrowtail=dot, dir=both];
523
+
524
+  recycle_core_models_InvoiceLine -> recycle_core_models_TimestampedModel
525
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
526
+
527
+  recycle_core_models_Payment -> recycle_core_models_Invoice
528
+  [label=" invoice (payments)"] [arrowhead=none, arrowtail=dot, dir=both];
529
+
530
+  recycle_core_models_Payment -> recycle_core_models_TimestampedModel
531
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
532
+
533
+  recycle_core_models_Payout -> orgs_models_Organization
534
+  [label=" organization (payouts)"] [arrowhead=none, arrowtail=dot, dir=both];
535
+
536
+  recycle_core_models_Payout -> recycle_core_models_Customer
537
+  [label=" customer (payouts)"] [arrowhead=none, arrowtail=dot, dir=both];
538
+
539
+  recycle_core_models_Payout -> recycle_core_models_PickupOrder
540
+  [label=" pickup (payouts)"] [arrowhead=none, arrowtail=dot, dir=both];
541
+
542
+  recycle_core_models_Payout -> recycle_core_models_TimestampedModel
543
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
544
+
545
+  recycle_core_models_Document -> orgs_models_Organization
546
+  [label=" organization (documents)"] [arrowhead=none, arrowtail=dot, dir=both];
547
+  django_contrib_contenttypes_models_ContentType [label=<
548
+  <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
549
+  <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
550
+  <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">ContentType</FONT>
551
+  </TD></TR>
552
+  </TABLE>
553
+  >]
554
+  recycle_core_models_Document -> django_contrib_contenttypes_models_ContentType
555
+  [label=" content_type (document)"] [arrowhead=none, arrowtail=dot, dir=both];
556
+  django_contrib_auth_models_User [label=<
557
+  <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
558
+  <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
559
+  <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
560
+  </TD></TR>
561
+  </TABLE>
562
+  >]
563
+  recycle_core_models_Document -> django_contrib_auth_models_User
564
+  [label=" uploaded_by (document)"] [arrowhead=none, arrowtail=dot, dir=both];
565
+
566
+  recycle_core_models_Document -> recycle_core_models_TimestampedModel
567
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
568
+
569
+  recycle_core_models_AuditLog -> orgs_models_Organization
570
+  [label=" organization (audit_logs)"] [arrowhead=none, arrowtail=dot, dir=both];
571
+  django_contrib_auth_models_User [label=<
572
+  <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
573
+  <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
574
+  <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
575
+  </TD></TR>
576
+  </TABLE>
577
+  >]
578
+  recycle_core_models_AuditLog -> django_contrib_auth_models_User
579
+  [label=" user (auditlog)"] [arrowhead=none, arrowtail=dot, dir=both];
580
+  django_contrib_contenttypes_models_ContentType [label=<
581
+  <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
582
+  <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
583
+  <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">ContentType</FONT>
584
+  </TD></TR>
585
+  </TABLE>
586
+  >]
587
+  recycle_core_models_AuditLog -> django_contrib_contenttypes_models_ContentType
588
+  [label=" content_type (auditlog)"] [arrowhead=none, arrowtail=dot, dir=both];
589
+
590
+  recycle_core_models_AuditLog -> recycle_core_models_TimestampedModel
591
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
592
+
593
+  recycle_core_models_ScrapListing -> orgs_models_Organization
594
+  [label=" organization (scrap_listings)"] [arrowhead=none, arrowtail=dot, dir=both];
595
+
596
+  recycle_core_models_ScrapListing -> recycle_core_models_Customer
597
+  [label=" customer (scrap_listings)"] [arrowhead=none, arrowtail=dot, dir=both];
598
+
599
+  recycle_core_models_ScrapListing -> recycle_core_models_CustomerSite
600
+  [label=" site (scrap_listings)"] [arrowhead=none, arrowtail=dot, dir=both];
601
+  django_contrib_auth_models_User [label=<
602
+  <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
603
+  <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
604
+  <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
605
+  </TD></TR>
606
+  </TABLE>
607
+  >]
608
+  recycle_core_models_ScrapListing -> django_contrib_auth_models_User
609
+  [label=" created_by (created_listings)"] [arrowhead=none, arrowtail=dot, dir=both];
610
+
611
+  recycle_core_models_ScrapListing -> recycle_core_models_TimestampedModel
612
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
613
+
614
+  recycle_core_models_ScrapListingItem -> recycle_core_models_ScrapListing
615
+  [label=" listing (items)"] [arrowhead=none, arrowtail=dot, dir=both];
616
+
617
+  recycle_core_models_ScrapListingItem -> recycle_core_models_Material
618
+  [label=" material (scraplistingitem)"] [arrowhead=none, arrowtail=dot, dir=both];
619
+
620
+  recycle_core_models_ScrapListingItem -> recycle_core_models_TimestampedModel
621
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
622
+
623
+  recycle_core_models_ScrapBid -> recycle_core_models_ScrapListing
624
+  [label=" listing (bids)"] [arrowhead=none, arrowtail=dot, dir=both];
625
+
626
+  recycle_core_models_ScrapBid -> orgs_models_Organization
627
+  [label=" bidder_org (bids)"] [arrowhead=none, arrowtail=dot, dir=both];
628
+  django_contrib_auth_models_User [label=<
629
+  <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
630
+  <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
631
+  <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
632
+  </TD></TR>
633
+  </TABLE>
634
+  >]
635
+  recycle_core_models_ScrapBid -> django_contrib_auth_models_User
636
+  [label=" bidder_user (bids)"] [arrowhead=none, arrowtail=dot, dir=both];
637
+
638
+  recycle_core_models_ScrapBid -> recycle_core_models_TimestampedModel
639
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
640
+
641
+  recycle_core_models_ScrapAward -> recycle_core_models_ScrapListing
642
+  [label=" listing (award)"] [arrowhead=none, arrowtail=none, dir=both];
643
+
644
+  recycle_core_models_ScrapAward -> recycle_core_models_ScrapBid
645
+  [label=" winning_bid (awards)"] [arrowhead=none, arrowtail=dot, dir=both];
646
+
647
+  recycle_core_models_ScrapAward -> recycle_core_models_PickupOrder
648
+  [label=" pickup (awards)"] [arrowhead=none, arrowtail=dot, dir=both];
649
+
650
+  recycle_core_models_ScrapAward -> recycle_core_models_TimestampedModel
651
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
652
+
653
+  recycle_core_models_ScrapListingInvite -> recycle_core_models_ScrapListing
654
+  [label=" listing (invites)"] [arrowhead=none, arrowtail=dot, dir=both];
655
+
656
+  recycle_core_models_ScrapListingInvite -> orgs_models_Organization
657
+  [label=" invited_org (listing_invites)"] [arrowhead=none, arrowtail=dot, dir=both];
658
+  django_contrib_auth_models_User [label=<
659
+  <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
660
+  <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
661
+  <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
662
+  </TD></TR>
663
+  </TABLE>
664
+  >]
665
+  recycle_core_models_ScrapListingInvite -> django_contrib_auth_models_User
666
+  [label=" invited_user (listing_invites)"] [arrowhead=none, arrowtail=dot, dir=both];
667
+
668
+  recycle_core_models_ScrapListingInvite -> recycle_core_models_TimestampedModel
669
+  [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
670
+
671
+
672
+}

BIN
erd.pdf


BIN
erd.png


+ 4 - 1
public_frontend/forms.py

@@ -4,12 +4,16 @@ from django import forms
4 4
 
5 5
 
6 6
 class PickupRequestForm(forms.Form):
7
+    class MultiFileInput(forms.ClearableFileInput):
8
+        allow_multiple_selected = True
9
+
7 10
     name = forms.CharField(max_length=255)
8 11
     email = forms.EmailField(required=False)
9 12
     phone = forms.CharField(max_length=64, required=False)
10 13
     address = forms.CharField(widget=forms.Textarea)
11 14
     preferred_at = forms.DateTimeField(required=False, widget=forms.DateTimeInput(attrs={"type": "datetime-local"}))
12 15
     materials = forms.CharField(label="Materials/Notes", widget=forms.Textarea, required=False)
16
+    photos = forms.FileField(required=False, widget=MultiFileInput, help_text="Optional: upload photos of scrap")
13 17
 
14 18
 
15 19
 class ContactForm(forms.Form):
@@ -18,4 +22,3 @@ class ContactForm(forms.Form):
18 22
     phone = forms.CharField(max_length=64, required=False)
19 23
     subject = forms.CharField(max_length=255, required=False)
20 24
     message = forms.CharField(widget=forms.Textarea)
21
-

+ 5 - 1
public_frontend/templates/public_frontend/home.html

@@ -76,7 +76,7 @@
76 76
   {# Pickup Request Section #}
77 77
   <section id="pickup-request">
78 78
     <h2 class="text-2xl font-semibold mb-3">Request a Pickup</h2>
79
-    <form method="post" action="{% url 'public_frontend:pickup_request' %}" class="bg-white rounded-lg shadow-md p-4 grid gap-4">
79
+    <form method="post" enctype="multipart/form-data" action="{% url 'public_frontend:pickup_request' %}" class="bg-white rounded-lg shadow-md p-4 grid gap-4">
80 80
       {% csrf_token %}
81 81
       <div>
82 82
         <label class="block text-sm font-medium mb-1">Name</label>
@@ -101,6 +101,10 @@
101 101
         <textarea name="materials" class="w-full border rounded px-3 py-2" rows="4">{{ pickup_form.materials.value|default:'' }}</textarea>
102 102
       </div>
103 103
       <div>
104
+        <label class="block text-sm font-medium mb-1">Photos (optional)</label>
105
+        <input type="file" name="photos" multiple accept="image/*" class="w-full border rounded px-3 py-2">
106
+      </div>
107
+      <div>
104 108
         <button class="btn-primary" type="submit">Submit Request</button>
105 109
       </div>
106 110
     </form>

+ 1 - 2
public_frontend/templates/public_frontend/materials_list.html

@@ -14,7 +14,7 @@
14 14
       {% for m in materials %}
15 15
         <tr class="border-t">
16 16
           <td class="px-4 py-2">{{ m.name }}</td>
17
-          <td class="px-4 py-2">{{ m.category.name }}</td>
17
+          <td class="px-4 py-2">{{ m.get_category_display }}</td>
18 18
           <td class="px-4 py-2">{{ m.get_default_unit_display }}</td>
19 19
         </tr>
20 20
       {% empty %}
@@ -24,4 +24,3 @@
24 24
   </table>
25 25
   </div>
26 26
 {% endblock %}
27
-

+ 5 - 2
public_frontend/templates/public_frontend/pickup_request.html

@@ -2,7 +2,7 @@
2 2
 {% block title %}Request Pickup{% endblock %}
3 3
 {% block content %}
4 4
 <h1 class="text-xl font-semibold mb-4">Request a Pickup</h1>
5
-<form method="post" class="bg-white rounded shadow p-4 grid gap-4">
5
+<form method="post" enctype="multipart/form-data" class="bg-white rounded shadow p-4 grid gap-4">
6 6
   {% csrf_token %}
7 7
   <div>
8 8
     <label class="block text-sm font-medium mb-1">Name</label>
@@ -35,9 +35,12 @@
35 35
     <textarea name="materials" class="w-full border rounded px-3 py-2" rows="4">{{ form.materials.value|default:'' }}</textarea>
36 36
   </div>
37 37
   <div>
38
+    <label class="block text-sm font-medium mb-1">Photos (optional)</label>
39
+    <input type="file" name="photos" multiple accept="image/*" class="w-full border rounded px-3 py-2">
40
+  </div>
41
+  <div>
38 42
     <button class="btn-primary" type="submit">Submit Request</button>
39 43
   </div>
40 44
 </form>
41 45
 <style>.btn-primary{background:#1d4ed8;color:white;padding:.5rem .75rem;border-radius:.375rem}</style>
42 46
 {% endblock %}
43
-

+ 20 - 17
public_frontend/views.py

@@ -12,6 +12,10 @@ from cms.models import Post, PostCategory
12 12
 
13 13
 from .forms import PickupRequestForm, ContactForm
14 14
 from .models import Lead
15
+from recycle_core.controllers.pickup_request import (
16
+    PickupRequestController,
17
+    PickupRequestData,
18
+)
15 19
 
16 20
 
17 21
 def home(request):
@@ -98,30 +102,29 @@ def service_detail(request, pk: int):
98 102
 
99 103
 def pickup_request(request):
100 104
     org = getattr(request, "org", None)
101
-    form = PickupRequestForm(request.POST or None)
105
+    form = PickupRequestForm(request.POST or None, request.FILES or None)
102 106
     if request.method == "POST":
103 107
         if not org:
104 108
             messages.error(request, "Organization context missing.")
105 109
             return redirect("public_frontend:pickup_request")
106 110
         if form.is_valid():
107
-            # Store as a Lead for staff to process; PickupOrder requires customer/site
108
-            message = (
109
-                f"Pickup Request\n"
110
-                f"Address: {form.cleaned_data.get('address')}\n"
111
-                f"Preferred: {form.cleaned_data.get('preferred_at') or ''}\n"
112
-                f"Materials: {form.cleaned_data.get('materials') or ''}"
113
-            )
114
-            Lead.objects.create(
111
+            ctrl = PickupRequestController()
112
+            data = PickupRequestData(
115 113
                 organization=org,
116
-                name=form.cleaned_data.get('name'),
117
-                email=form.cleaned_data.get('email',''),
118
-                phone=form.cleaned_data.get('phone',''),
119
-                subject='Pickup Request',
120
-                message=message,
121
-                source='pickup_request',
114
+                name=form.cleaned_data.get("name"),
115
+                email=form.cleaned_data.get("email", ""),
116
+                phone=form.cleaned_data.get("phone", ""),
117
+                address=form.cleaned_data.get("address", ""),
118
+                materials=form.cleaned_data.get("materials", ""),
119
+                preferred_at=form.cleaned_data.get("preferred_at"),
120
+                files=request.FILES.getlist("photos") if hasattr(request, "FILES") and "photos" in request.FILES else [],
122 121
             )
123
-            messages.success(request, "Thanks! Your pickup request was submitted.")
124
-            return redirect("public_frontend:home")
122
+            result = ctrl.submit(data)
123
+            if result.ok:
124
+                messages.success(request, "Thanks! Your pickup request was submitted.")
125
+                return redirect("public_frontend:home")
126
+            else:
127
+                messages.error(request, result.error or "Unable to submit your request.")
125 128
         messages.error(request, "Please correct the errors below.")
126 129
     return render(request, "public_frontend/pickup_request.html", {"form": form, "org": org})
127 130
 

+ 5 - 1
recycle_core/admin.py

@@ -39,9 +39,13 @@ class MaterialCategoryAdmin(OrgScopedAdmin):
39 39
 
40 40
 @admin.register(models.Material)
41 41
 class MaterialAdmin(OrgScopedAdmin):
42
-    list_display = ("name", "code", "category", "organization", "default_unit")
42
+    list_display = ("name", "code", "get_category_display", "organization", "default_unit")
43 43
     list_filter = ("default_unit", "category")
44 44
     search_fields = ("name", "code")
45
+    class MaterialImageInline(admin.TabularInline):
46
+        model = models.MaterialImage
47
+        extra = 1
48
+    inlines = [MaterialImageInline]
45 49
 
46 50
 
47 51
 @admin.register(models.PriceList)

+ 2 - 0
recycle_core/controllers/__init__.py

@@ -0,0 +1,2 @@
1
+from __future__ import annotations
2
+

+ 80 - 0
recycle_core/controllers/pickup_request.py

@@ -0,0 +1,80 @@
1
+from __future__ import annotations
2
+
3
+from dataclasses import dataclass
4
+from typing import Iterable, List, Optional
5
+from django.contrib.contenttypes.models import ContentType
6
+from django.utils import timezone
7
+
8
+from orgs.models import Organization
9
+from recycle_core.models import Document
10
+
11
+
12
+@dataclass
13
+class PickupRequestData:
14
+    organization: Organization
15
+    name: str
16
+    email: str = ""
17
+    phone: str = ""
18
+    address: str = ""
19
+    materials: str = ""
20
+    preferred_at: Optional[timezone.datetime] = None
21
+    files: Optional[Iterable] = None  # iterable of UploadedFile
22
+
23
+
24
+@dataclass
25
+class PickupRequestResult:
26
+    ok: bool
27
+    lead_id: Optional[int] = None
28
+    error: Optional[str] = None
29
+    document_ids: List[int] = None
30
+
31
+
32
+class PickupRequestController:
33
+    """Application layer controller for the fast-path pickup request.
34
+
35
+    - Creates a Lead scoped to an Organization
36
+    - Stores any uploaded photos/documents as Document records attached to the Lead
37
+    """
38
+
39
+    def submit(self, data: PickupRequestData) -> PickupRequestResult:
40
+        from public_frontend.models import Lead  # import locally to avoid circular imports
41
+
42
+        try:
43
+            # Prepare message body for staff
44
+            message = (
45
+                f"Pickup Request\n"
46
+                f"Address: {data.address}\n"
47
+                f"Preferred: {data.preferred_at or ''}\n"
48
+                f"Materials: {data.materials or ''}"
49
+            )
50
+
51
+            lead = Lead.objects.create(
52
+                organization=data.organization,
53
+                name=data.name,
54
+                email=data.email or "",
55
+                phone=data.phone or "",
56
+                subject="Pickup Request",
57
+                message=message,
58
+                source="pickup_request",
59
+            )
60
+
61
+            # Attach uploaded files as Documents linked to the Lead
62
+            doc_ids: List[int] = []
63
+            if data.files:
64
+                ct = ContentType.objects.get_for_model(Lead)
65
+                for idx, f in enumerate(data.files):
66
+                    doc = Document.objects.create(
67
+                        organization=data.organization,
68
+                        file=f,
69
+                        kind="pickup_request",
70
+                        content_type=ct,
71
+                        object_id=lead.id,
72
+                        uploaded_by=None,
73
+                    )
74
+                    doc_ids.append(doc.id)
75
+
76
+            return PickupRequestResult(ok=True, lead_id=lead.id, document_ids=doc_ids)
77
+
78
+        except Exception as e:
79
+            return PickupRequestResult(ok=False, error=str(e), document_ids=[])
80
+

+ 51 - 1
recycle_core/forms.py

@@ -1,4 +1,5 @@
1 1
 from django import forms
2
+from django.core.exceptions import ValidationError
2 3
 from django.contrib.auth import get_user_model
3 4
 from decimal import Decimal
4 5
 from django.utils import timezone
@@ -7,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType
7 8
 from .models import (
8 9
     MaterialCategory,
9 10
     Material,
11
+    MaterialImage,
10 12
     ProvidedService,
11 13
     Customer,
12 14
     CustomerSite,
@@ -21,10 +23,58 @@ class MaterialCategoryForm(forms.ModelForm):
21 23
         fields = ["organization", "name"]
22 24
 
23 25
 
26
+class MultiFileInput(forms.ClearableFileInput):
27
+    allow_multiple_selected = True
28
+
29
+
30
+class MultiImageField(forms.Field):
31
+    widget = MultiFileInput
32
+
33
+    def __init__(self, *args, **kwargs):
34
+        kwargs.setdefault("required", False)
35
+        super().__init__(*args, **kwargs)
36
+
37
+    def to_python(self, data):
38
+        return data
39
+
40
+    def validate(self, value):
41
+        # Basic required check; skip per-file validation here
42
+        if self.required and not value:
43
+            raise ValidationError("This field is required.")
44
+
45
+
24 46
 class MaterialForm(forms.ModelForm):
47
+    images = MultiImageField(help_text="Upload one or more sample images (optional)")
48
+
25 49
     class Meta:
26 50
         model = Material
27
-        fields = ["organization", "category", "name", "code", "default_unit"]
51
+        fields = ["organization", "category", "name", "code", "default_unit", "images"]
52
+
53
+    def save(self, commit=True):
54
+        instance = super().save(commit=commit)
55
+        files = self.files.getlist("images") if hasattr(self, "files") else []
56
+        if commit and files:
57
+            # Instance has a PK; we can create images now
58
+            order_start = instance.images.count()
59
+            for i, f in enumerate(files):
60
+                MaterialImage.objects.create(material=instance, image=f, display_order=order_start + i)
61
+        else:
62
+            # Defer image saving until caller completes save
63
+            self._pending_images = files
64
+        return instance
65
+
66
+    def save_images(self, instance: Material | None = None):
67
+        """Persist any pending images after the Material has been saved."""
68
+        if not hasattr(self, "_pending_images"):
69
+            return
70
+        target = instance or getattr(self, "instance", None)
71
+        if not target or not getattr(target, "pk", None):
72
+            return
73
+        order_start = target.images.count()
74
+        for i, f in enumerate(self._pending_images or []):
75
+            MaterialImage.objects.create(material=target, image=f, display_order=order_start + i)
76
+        # Clear pending list
77
+        self._pending_images = []
28 78
 
29 79
 
30 80
 class CustomerForm(forms.ModelForm):

+ 69 - 4
recycle_core/management/commands/seed_ecoloop.py

@@ -35,6 +35,7 @@ class Command(BaseCommand):
35 35
     def add_arguments(self, parser):
36 36
         parser.add_argument("--org", default="DEMO", help="Organization code/id/name to seed (default: DEMO)")
37 37
         parser.add_argument("--bidder-org", dest="bidder_org", default="REC1", help="Bidder org code/id/name (default: REC1)")
38
+        parser.add_argument("--reset", action="store_true", help="Delete existing data for the target orgs before seeding")
38 39
 
39 40
     def handle(self, *args, **options):
40 41
         now = timezone.now()
@@ -59,6 +60,70 @@ class Command(BaseCommand):
59 60
         org = _resolve_org(org_ident, default_name=("Ecoloop " + str(org_ident)))
60 61
         bidder_org = _resolve_org(bidder_ident, default_name="Recycler Co.")
61 62
 
63
+        # Optionally reset existing demo data (scoped to the selected orgs)
64
+        if options.get("reset"):
65
+            from recycle_core.models import (
66
+                ScrapAward,
67
+                ScrapBid,
68
+                ScrapListingInvite,
69
+                ScrapListingItem,
70
+                ScrapListing,
71
+                WeighLine,
72
+                WeighTicket,
73
+                PickupItem,
74
+                PickupOrder,
75
+                InvoiceLine,
76
+                Invoice,
77
+                Payment,
78
+                Payout,
79
+                ServiceAgreement,
80
+                CustomerSite,
81
+                Customer,
82
+                PriceListItem,
83
+                PriceList,
84
+                Material,
85
+                MaterialCategory,
86
+                ProvidedService,
87
+            )
88
+
89
+            def _wipe_for(o: Organization):
90
+                # Marketplace
91
+                ScrapAward.objects.filter(listing__organization=o).delete()
92
+                ScrapBid.objects.filter(listing__organization=o).delete()
93
+                ScrapListingInvite.objects.filter(listing__organization=o).delete()
94
+                ScrapListingItem.objects.filter(listing__organization=o).delete()
95
+                ScrapListing.objects.filter(organization=o).delete()
96
+
97
+                # Operations
98
+                WeighLine.objects.filter(ticket__pickup__organization=o).delete()
99
+                WeighTicket.objects.filter(pickup__organization=o).delete()
100
+                PickupItem.objects.filter(pickup__organization=o).delete()
101
+                PickupOrder.objects.filter(organization=o).delete()
102
+
103
+                # Billing
104
+                InvoiceLine.objects.filter(invoice__organization=o).delete()
105
+                Payment.objects.filter(invoice__organization=o).delete()
106
+                Invoice.objects.filter(organization=o).delete()
107
+                Payout.objects.filter(organization=o).delete()
108
+
109
+                # Customers and agreements
110
+                ServiceAgreement.objects.filter(customer__organization=o).delete()
111
+                CustomerSite.objects.filter(customer__organization=o).delete()
112
+                Customer.objects.filter(organization=o).delete()
113
+
114
+                # Pricing
115
+                PriceListItem.objects.filter(price_list__organization=o).delete()
116
+                PriceList.objects.filter(organization=o).delete()
117
+
118
+                # Inventory and services
119
+                Material.objects.filter(organization=o).delete()
120
+                ProvidedService.objects.filter(organization=o).delete()
121
+                MaterialCategory.objects.filter(organization=o).delete()
122
+
123
+            _wipe_for(org)
124
+            _wipe_for(bidder_org)
125
+            self.stdout.write(self.style.WARNING("Existing data removed for selected orgs (reset)."))
126
+
62 127
         # Users
63 128
         manager = User.objects.filter(username="manager").first()
64 129
         if not manager:
@@ -80,10 +145,10 @@ class Command(BaseCommand):
80 145
         metals, _ = MaterialCategory.objects.get_or_create(organization=org, name="Metals")
81 146
         paper, _ = MaterialCategory.objects.get_or_create(organization=org, name="Paper")
82 147
 
83
-        pet, _ = Material.objects.get_or_create(organization=org, category=plastics, name="PET", defaults={"default_unit": Material.UNIT_KG})
84
-        hdpe, _ = Material.objects.get_or_create(organization=org, category=plastics, name="HDPE", defaults={"default_unit": Material.UNIT_KG})
85
-        can, _ = Material.objects.get_or_create(organization=org, category=metals, name="Aluminum Can", defaults={"default_unit": Material.UNIT_KG})
86
-        cardboard, _ = Material.objects.get_or_create(organization=org, category=paper, name="Cardboard", defaults={"default_unit": Material.UNIT_KG})
148
+        pet, _ = Material.objects.get_or_create(organization=org, category="Plastics", name="PET", defaults={"default_unit": Material.UNIT_KG})
149
+        hdpe, _ = Material.objects.get_or_create(organization=org, category="Plastics", name="HDPE", defaults={"default_unit": Material.UNIT_KG})
150
+        can, _ = Material.objects.get_or_create(organization=org, category="Metals", name="Aluminum Can", defaults={"default_unit": Material.UNIT_KG})
151
+        cardboard, _ = Material.objects.get_or_create(organization=org, category="Paper", name="Cardboard", defaults={"default_unit": Material.UNIT_KG})
87 152
 
88 153
         # Price list
89 154
         pl, _ = PriceList.objects.get_or_create(

+ 18 - 0
recycle_core/migrations/0006_alter_materialcategory_name.py

@@ -0,0 +1,18 @@
1
+# Generated by Django 4.2.24 on 2025-09-22 09:17
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('recycle_core', '0005_providedservice_is_enabled'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AlterField(
14
+            model_name='materialcategory',
15
+            name='name',
16
+            field=models.CharField(choices=[('Plastics', 'Plastics'), ('Metals', 'Metals'), ('Paper', 'Paper'), ('Glass', 'Glass'), ('Electronics', 'Electronics'), ('Wood', 'Wood'), ('Rubber', 'Rubber'), ('Textiles', 'Textiles'), ('Organic', 'Organic'), ('Mixed', 'Mixed')], max_length=255),
17
+        ),
18
+    ]

+ 18 - 0
recycle_core/migrations/0007_alter_material_category.py

@@ -0,0 +1,18 @@
1
+# Generated by Django 4.2.24 on 2025-09-22 09:23
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('recycle_core', '0006_alter_materialcategory_name'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AlterField(
14
+            model_name='material',
15
+            name='category',
16
+            field=models.CharField(choices=[('Plastics', 'Plastics'), ('Metals', 'Metals'), ('Paper', 'Paper'), ('Glass', 'Glass'), ('Electronics', 'Electronics'), ('Wood', 'Wood'), ('Rubber', 'Rubber'), ('Textiles', 'Textiles'), ('Organic', 'Organic'), ('Mixed', 'Mixed')], max_length=64),
17
+        ),
18
+    ]

+ 29 - 0
recycle_core/migrations/0008_materialimage.py

@@ -0,0 +1,29 @@
1
+# Generated by Django 4.2.24 on 2025-09-22 09:28
2
+
3
+from django.db import migrations, models
4
+import django.db.models.deletion
5
+
6
+
7
+class Migration(migrations.Migration):
8
+
9
+    dependencies = [
10
+        ('recycle_core', '0007_alter_material_category'),
11
+    ]
12
+
13
+    operations = [
14
+        migrations.CreateModel(
15
+            name='MaterialImage',
16
+            fields=[
17
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18
+                ('created_at', models.DateTimeField(auto_now_add=True)),
19
+                ('updated_at', models.DateTimeField(auto_now=True)),
20
+                ('image', models.ImageField(upload_to='materials/%Y/%m/')),
21
+                ('caption', models.CharField(blank=True, max_length=255)),
22
+                ('display_order', models.PositiveIntegerField(default=0)),
23
+                ('material', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='recycle_core.material')),
24
+            ],
25
+            options={
26
+                'ordering': ['display_order', 'id'],
27
+            },
28
+        ),
29
+    ]

+ 30 - 2
recycle_core/models.py

@@ -27,7 +27,20 @@ class TimestampedModel(models.Model):
27 27
 
28 28
 class MaterialCategory(TimestampedModel):
29 29
     organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="material_categories")
30
-    name = models.CharField(max_length=255)
30
+    # Limit to a curated, preset set of category names
31
+    CATEGORY_CHOICES = (
32
+        ("Plastics", "Plastics"),
33
+        ("Metals", "Metals"),
34
+        ("Paper", "Paper"),
35
+        ("Glass", "Glass"),
36
+        ("Electronics", "Electronics"),
37
+        ("Wood", "Wood"),
38
+        ("Rubber", "Rubber"),
39
+        ("Textiles", "Textiles"),
40
+        ("Organic", "Organic"),
41
+        ("Mixed", "Mixed"),
42
+    )
43
+    name = models.CharField(max_length=255, choices=CATEGORY_CHOICES)
31 44
 
32 45
     class Meta:
33 46
         unique_together = ("organization", "name")
@@ -81,7 +94,9 @@ class ProvidedService(TimestampedModel):
81 94
 
82 95
 class Material(TimestampedModel):
83 96
     organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="materials")
84
-    category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, related_name="materials")
97
+    # Preset category choices (no FK)
98
+    CATEGORY_CHOICES = MaterialCategory.CATEGORY_CHOICES
99
+    category = models.CharField(max_length=64, choices=CATEGORY_CHOICES)
85 100
     name = models.CharField(max_length=255)
86 101
     code = models.CharField(max_length=64, blank=True)
87 102
     # unit choices keep MVP simple; conversions out of scope for now
@@ -102,6 +117,19 @@ class Material(TimestampedModel):
102 117
         return self.name
103 118
 
104 119
 
120
+class MaterialImage(TimestampedModel):
121
+    material = models.ForeignKey(Material, on_delete=models.CASCADE, related_name="images")
122
+    image = models.ImageField(upload_to="materials/%Y/%m/")
123
+    caption = models.CharField(max_length=255, blank=True)
124
+    display_order = models.PositiveIntegerField(default=0)
125
+
126
+    class Meta:
127
+        ordering = ["display_order", "id"]
128
+
129
+    def __str__(self) -> str:
130
+        return self.caption or f"MaterialImage #{self.id}"
131
+
132
+
105 133
 class PriceList(TimestampedModel):
106 134
     organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="price_lists")
107 135
     name = models.CharField(max_length=255)

+ 22 - 1
recycle_core/templates/recycle_core/material_form.html

@@ -5,7 +5,28 @@
5 5
 {% render_breadcrumbs breadcrumbs %}
6 6
 <div class="bg-white rounded shadow p-4">
7 7
   <h1 class="text-xl font-semibold mb-4">Edit Material</h1>
8
-  <form method="post">
8
+
9
+  {% if item %}
10
+  {% with imgs=item.images.all %}
11
+  {% if imgs %}
12
+  <div class="mb-4">
13
+    <h2 class="text-lg font-medium mb-2">Existing Images</h2>
14
+    <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
15
+      {% for im in imgs %}
16
+        <div class="border rounded p-2 bg-white flex flex-col items-center gap-2">
17
+          <img src="{{ im.image.url }}" alt="{{ im.caption|default:'Material image' }}" class="w-28 h-28 object-cover rounded" />
18
+          {% if im.caption %}
19
+            <div class="text-xs text-gray-600 text-center">{{ im.caption }}</div>
20
+          {% endif %}
21
+        </div>
22
+      {% endfor %}
23
+    </div>
24
+  </div>
25
+  {% endif %}
26
+  {% endwith %}
27
+  {% endif %}
28
+
29
+  <form method="post" enctype="multipart/form-data">
9 30
     {% csrf_token %}
10 31
     {{ form|crispy }}
11 32
     <div class="mt-3 flex gap-2">

+ 1 - 1
recycle_core/templates/recycle_core/materials_list.html

@@ -54,7 +54,7 @@
54 54
         {% for m in materials %}
55 55
           <tr>
56 56
             <td class="px-4 py-2">{{ m.organization.name }}</td>
57
-            <td class="px-4 py-2">{{ m.category.name }}</td>
57
+            <td class="px-4 py-2">{{ m.get_category_display }}</td>
58 58
             <td class="px-4 py-2">{{ m.name }}</td>
59 59
             <td class="px-4 py-2">{{ m.code }}</td>
60 60
             <td class="px-4 py-2">{{ m.get_default_unit_display }}</td>

+ 64 - 1
recycle_core/tests.py

@@ -1,3 +1,66 @@
1 1
 from django.test import TestCase
2
+from django.core.files.uploadedfile import SimpleUploadedFile
2 3
 
3
-# Create your tests here.
4
+from orgs.models import Organization
5
+from public_frontend.models import Lead
6
+from .models import Document
7
+from .controllers.pickup_request import PickupRequestController, PickupRequestData
8
+
9
+
10
+class PickupRequestControllerTests(TestCase):
11
+    def setUp(self) -> None:
12
+        self.org = Organization.objects.create(name="Test Org", code="TEST")
13
+        self.ctrl = PickupRequestController()
14
+
15
+    def test_submit_without_files_creates_lead_only(self):
16
+        data = PickupRequestData(
17
+            organization=self.org,
18
+            name="Alice",
19
+            email="alice@example.com",
20
+            phone="+1 555 0000",
21
+            address="123 Road",
22
+            materials="PET bottles",
23
+            preferred_at=None,
24
+            files=[],
25
+        )
26
+
27
+        result = self.ctrl.submit(data)
28
+        self.assertTrue(result.ok)
29
+        self.assertIsNotNone(result.lead_id)
30
+        self.assertEqual(result.document_ids, [])
31
+
32
+        lead = Lead.objects.get(pk=result.lead_id)
33
+        self.assertEqual(lead.organization, self.org)
34
+        self.assertEqual(lead.name, "Alice")
35
+        self.assertEqual(lead.subject, "Pickup Request")
36
+        self.assertEqual(lead.source, "pickup_request")
37
+
38
+        self.assertEqual(Document.objects.filter(object_id=lead.id).count(), 0)
39
+
40
+    def test_submit_with_files_creates_documents(self):
41
+        file1 = SimpleUploadedFile("photo1.jpg", b"fakejpegdata1", content_type="image/jpeg")
42
+        file2 = SimpleUploadedFile("photo2.jpg", b"fakejpegdata2", content_type="image/jpeg")
43
+
44
+        data = PickupRequestData(
45
+            organization=self.org,
46
+            name="Bob",
47
+            email="bob@example.com",
48
+            phone="+1 555 1111",
49
+            address="456 Avenue",
50
+            materials="Aluminum cans",
51
+            preferred_at=None,
52
+            files=[file1, file2],
53
+        )
54
+
55
+        result = self.ctrl.submit(data)
56
+        self.assertTrue(result.ok)
57
+        self.assertIsNotNone(result.lead_id)
58
+        self.assertEqual(len(result.document_ids), 2)
59
+
60
+        lead = Lead.objects.get(pk=result.lead_id)
61
+        docs = Document.objects.filter(object_id=lead.id).order_by("id")
62
+        self.assertEqual(docs.count(), 2)
63
+        for d in docs:
64
+            self.assertEqual(d.organization, self.org)
65
+            self.assertEqual(d.kind, "pickup_request")
66
+            self.assertEqual(d.content_object, lead)

+ 9 - 4
recycle_core/views.py

@@ -72,7 +72,7 @@ def owner_required(view_func):
72 72
 @breadcrumbs(label="Materials", name="re_materials")
73 73
 def materials_list(request):
74 74
     # Create forms
75
-    mat_form = MaterialForm(request.POST or None)
75
+    mat_form = MaterialForm(request.POST or None, request.FILES or None)
76 76
     cat_form = MaterialCategoryForm(request.POST or None)
77 77
 
78 78
     # Restrict organization choices in forms to current org
@@ -91,6 +91,11 @@ def materials_list(request):
91 91
                 if getattr(request, "org", None) is not None:
92 92
                     obj.organization = request.org
93 93
                 obj.save()
94
+                # Save any uploaded images deferred by the form
95
+                try:
96
+                    mat_form.save_images(instance=obj)
97
+                except Exception:
98
+                    pass
94 99
                 messages.success(request, "Material created.")
95 100
                 return redirect("recycle_core:materials_list")
96 101
             else:
@@ -109,14 +114,14 @@ def materials_list(request):
109 114
     # Filters via django-filter to match list pattern
110 115
     class MaterialFilter(filters.FilterSet):
111 116
         organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
112
-        category = filters.ModelChoiceFilter(queryset=MaterialCategory.objects.all())
117
+        category = filters.ChoiceFilter(choices=Material.CATEGORY_CHOICES)
113 118
         name = filters.CharFilter(field_name="name", lookup_expr="icontains")
114 119
 
115 120
         class Meta:
116 121
             model = Material
117 122
             fields = ["organization", "category", "name"]
118 123
 
119
-    base_mats = Material.objects.select_related("organization", "category").order_by("organization_id", "name")
124
+    base_mats = Material.objects.select_related("organization").order_by("organization_id", "name")
120 125
     mat_filter = MaterialFilter(request.GET, queryset=base_mats)
121 126
     mats = mat_filter.qs
122 127
     # Scope to current organization if present
@@ -248,7 +253,7 @@ def org_user_delete(request, pk: int):
248 253
 def material_edit(request, pk: int):
249 254
     item = get_object_or_404(Material, pk=pk)
250 255
     if request.method == "POST":
251
-        form = MaterialForm(request.POST, instance=item)
256
+        form = MaterialForm(request.POST, request.FILES, instance=item)
252 257
         if form.is_valid():
253 258
             form.save()
254 259
             messages.success(request, "Material updated.")

+ 4 - 0
requirements.txt

@@ -14,4 +14,8 @@ django-browser-reload
14 14
 django-allauth[socialaccount]
15 15
 django-markdownfield
16 16
 django-mptt
17
+django-extensions
18
+# Choose one of these for graph_models rendering
19
+# pygraphviz is preferred; alternatively install pydotplus and graphviz
20
+pygraphviz
17 21
 django-npm

BIN
seq.png


+ 59 - 0
seq.txt

@@ -0,0 +1,59 @@
1
+@startuml
2
+title Request Pickup (Fast Path)
3
+actor "Factory Officer" as FO
4
+participant "Public Site (FE)" as Web
5
+participant "Backend (Django)" as API
6
+actor "Staff (Web Admin)" as Staff
7
+actor Driver
8
+participant "Weigh Station" as Scale
9
+participant Billing
10
+
11
+FO -> Web: Open "Sell Scrap" (Request Pickup)
12
+Web --> FO: Show form (materials, qty, photos, address, time)
13
+FO -> Web: Submit form (+photos)
14
+Web -> API: POST /pickup-request
15
+API -> API: Create Lead (org, details)
16
+API --> Staff: Notify (inbox/email)
17
+Staff -> API: Create/attach Customer + Site
18
+Staff -> API: Create PickupOrder (status=requested)
19
+Staff -> API: Schedule + Assign Driver
20
+API --> FO: Confirmation (schedule)
21
+Driver -> FO: Arrive and collect
22
+Driver -> Scale: Weigh materials
23
+Scale -> API: Record WeighTicket + lines
24
+API -> Billing: Generate Invoice or Payout
25
+Billing --> FO: Invoice/Payout issued
26
+FO -> Billing: Pay / Receive funds
27
+API -> API: Mark Pickup completed
28
+@enduml
29
+
30
+@startuml
31
+title Get Bids (Marketplace Path)
32
+actor "Factory Officer" as FO
33
+participant "Public Site (FE)" as Web
34
+participant "Backend (Django)" as API
35
+actor "Staff (Web Admin)" as Staff
36
+actor "Recycler(s)" as Rec
37
+actor Driver
38
+participant "Weigh Station" as Scale
39
+participant Billing
40
+
41
+FO -> Web: "Sell Scrap" → Get Bids
42
+Web --> FO: Listing form (title, materials, qty, photos, reserve, ends)
43
+FO -> Web: Submit request
44
+Web -> API: POST listing-request
45
+API -> API: Create Draft ScrapListing (or Lead: listing_request)
46
+Staff -> API: Review + Publish (public or invite-only)
47
+API --> Rec: Listing visible / invites sent
48
+Rec -> API: Place Bid(s)
49
+Staff -> API: Close listing at end time
50
+Staff -> API: Award winning bid
51
+API -> API: Create PickupOrder from award
52
+Driver -> FO: Collect materials
53
+Driver -> Scale: Weigh materials
54
+Scale -> API: Record WeighTicket + lines
55
+API -> Billing: Generate Invoice or Payout
56
+Billing --> FO: Invoice/Payout issued
57
+API -> API: Mark Pickup completed
58
+@enduml
59
+

BIN
seq_001.png


golf/tge - Gogs: Simplico Git Service

Keine Beschreibung

LICENSE 1.1KB

123456789101112131415161718192021
  1. The MIT License (MIT)
  2. Copyright 2017 Andrey Sitnik <andrey@sitnik.ru>
  3. Permission is hereby granted, free of charge, to any person obtaining a copy of
  4. this software and associated documentation files (the "Software"), to deal in
  5. the Software without restriction, including without limitation the rights to
  6. use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
  7. the Software, and to permit persons to whom the Software is furnished to do so,
  8. subject to the following conditions:
  9. The above copyright notice and this permission notice shall be included in all
  10. copies or substantial portions of the Software.
  11. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  12. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
  13. FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
  14. COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
  15. IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  16. CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.