Нет описания

forms.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. from django import forms
  2. from django.core.exceptions import ValidationError
  3. from django.contrib.auth import get_user_model
  4. from decimal import Decimal
  5. from django.utils import timezone
  6. from django.contrib.contenttypes.models import ContentType
  7. from .models import (
  8. MaterialCategory,
  9. Material,
  10. MaterialImage,
  11. ProvidedService,
  12. Customer,
  13. CustomerSite,
  14. MaterialEmissionFactor,
  15. PickupItem,
  16. )
  17. from markdownfield.widgets import MDEWidget
  18. from orgs.models import UserProfile
  19. class MaterialCategoryForm(forms.ModelForm):
  20. class Meta:
  21. model = MaterialCategory
  22. fields = ["organization", "name"]
  23. class MultiFileInput(forms.ClearableFileInput):
  24. allow_multiple_selected = True
  25. class MultiImageField(forms.Field):
  26. widget = MultiFileInput
  27. def __init__(self, *args, **kwargs):
  28. kwargs.setdefault("required", False)
  29. super().__init__(*args, **kwargs)
  30. def to_python(self, data):
  31. return data
  32. def validate(self, value):
  33. # Basic required check; skip per-file validation here
  34. if self.required and not value:
  35. raise ValidationError("This field is required.")
  36. class MaterialForm(forms.ModelForm):
  37. images = MultiImageField(help_text="Upload one or more sample images (optional)")
  38. class Meta:
  39. model = Material
  40. fields = ["organization", "category", "name", "code", "default_unit", "images"]
  41. def save(self, commit=True):
  42. instance = super().save(commit=commit)
  43. files = self.files.getlist("images") if hasattr(self, "files") else []
  44. if commit and files:
  45. # Instance has a PK; we can create images now
  46. order_start = instance.images.count()
  47. for i, f in enumerate(files):
  48. MaterialImage.objects.create(material=instance, image=f, display_order=order_start + i)
  49. else:
  50. # Defer image saving until caller completes save
  51. self._pending_images = files
  52. return instance
  53. def save_images(self, instance: Material | None = None):
  54. """Persist any pending images after the Material has been saved."""
  55. if not hasattr(self, "_pending_images"):
  56. return
  57. target = instance or getattr(self, "instance", None)
  58. if not target or not getattr(target, "pk", None):
  59. return
  60. order_start = target.images.count()
  61. for i, f in enumerate(self._pending_images or []):
  62. MaterialImage.objects.create(material=target, image=f, display_order=order_start + i)
  63. # Clear pending list
  64. self._pending_images = []
  65. class CustomerForm(forms.ModelForm):
  66. class Meta:
  67. model = Customer
  68. fields = ["organization", "name", "email", "phone", "billing_address", "price_list"]
  69. class CustomerSiteForm(forms.ModelForm):
  70. class Meta:
  71. model = CustomerSite
  72. fields = ["customer", "name", "address", "contact_name", "contact_phone", "contact_email"]
  73. class ProvidedServiceForm(forms.ModelForm):
  74. class Meta:
  75. model = ProvidedService
  76. fields = ["title", "description", "body", "display_order", "is_enabled"]
  77. widgets = {
  78. "description": forms.Textarea(attrs={"rows": 3}),
  79. "body": MDEWidget(),
  80. }
  81. # Operational forms ----------------------------------------------------------
  82. User = get_user_model()
  83. class PickupAssignForm(forms.Form):
  84. driver = forms.ModelChoiceField(queryset=User.objects.all(), required=True, label="Assign Driver")
  85. class PickupStatusForm(forms.Form):
  86. status = forms.ChoiceField(choices=(), required=True, label="Set Status")
  87. def __init__(self, *args, **kwargs):
  88. from .models import PickupOrder
  89. super().__init__(*args, **kwargs)
  90. self.fields["status"].choices = PickupOrder.STATUS_CHOICES
  91. class PaymentForm(forms.Form):
  92. amount = forms.DecimalField(max_digits=14, decimal_places=2)
  93. received_at = forms.DateTimeField(required=False, initial=timezone.now)
  94. reference = forms.CharField(max_length=128, required=False)
  95. class DocumentForm(forms.Form):
  96. organization = forms.ModelChoiceField(queryset=None)
  97. file = forms.FileField()
  98. kind = forms.CharField(max_length=64, required=False)
  99. content_type = forms.ModelChoiceField(queryset=ContentType.objects.all(), required=True, label="Attach To Model")
  100. object_id = forms.IntegerField(required=True, label="Object ID")
  101. def __init__(self, *args, **kwargs):
  102. from orgs.models import Organization as Org
  103. super().__init__(*args, **kwargs)
  104. self.fields["organization"].queryset = Org.objects.all()
  105. # User management ------------------------------------------------------------
  106. class UserCreateForm(forms.Form):
  107. username = forms.CharField(max_length=150)
  108. email = forms.EmailField(required=False)
  109. first_name = forms.CharField(max_length=150, required=False)
  110. last_name = forms.CharField(max_length=150, required=False)
  111. role = forms.ChoiceField(choices=UserProfile.ROLE_CHOICES)
  112. password1 = forms.CharField(widget=forms.PasswordInput)
  113. password2 = forms.CharField(widget=forms.PasswordInput)
  114. def clean_username(self):
  115. User = get_user_model()
  116. username = self.cleaned_data["username"].strip()
  117. if User.objects.filter(username__iexact=username).exists():
  118. raise forms.ValidationError("Username already taken")
  119. return username
  120. def clean(self):
  121. cleaned = super().clean()
  122. p1 = cleaned.get("password1")
  123. p2 = cleaned.get("password2")
  124. if p1 and p2 and p1 != p2:
  125. self.add_error("password2", "Passwords do not match")
  126. return cleaned
  127. class UserEditForm(forms.Form):
  128. email = forms.EmailField(required=False)
  129. first_name = forms.CharField(max_length=150, required=False)
  130. last_name = forms.CharField(max_length=150, required=False)
  131. role = forms.ChoiceField(choices=UserProfile.ROLE_CHOICES)
  132. is_active = forms.BooleanField(required=False, label="Active")
  133. password1 = forms.CharField(widget=forms.PasswordInput, required=False)
  134. password2 = forms.CharField(widget=forms.PasswordInput, required=False)
  135. def clean(self):
  136. cleaned = super().clean()
  137. p1 = cleaned.get("password1")
  138. p2 = cleaned.get("password2")
  139. if (p1 or p2) and p1 != p2:
  140. self.add_error("password2", "Passwords do not match")
  141. return cleaned
  142. class UserProfilePhotoForm(forms.ModelForm):
  143. remove_photo = forms.BooleanField(label="Remove current photo", required=False)
  144. class Meta:
  145. model = UserProfile
  146. fields = ["my_photo", "remove_photo"]
  147. def __init__(self, *args, **kwargs):
  148. super().__init__(*args, **kwargs)
  149. self.fields["my_photo"].widget = forms.ClearableFileInput(attrs={
  150. "accept": "image/*",
  151. "class": "hidden",
  152. "id": "id_my_photo",
  153. })
  154. def clean_my_photo(self):
  155. f = self.cleaned_data.get("my_photo")
  156. if not f:
  157. return f
  158. content_type = getattr(f, "content_type", "")
  159. if content_type and not content_type.startswith("image/"):
  160. raise ValidationError("Please upload an image file.")
  161. max_bytes = 5 * 1024 * 1024
  162. if getattr(f, "size", 0) and f.size > max_bytes:
  163. raise ValidationError("Image too large (max 5MB).")
  164. return f
  165. def save(self, commit: bool = True):
  166. instance: UserProfile = super().save(commit=False)
  167. remove = self.cleaned_data.get("remove_photo", False)
  168. new_file = self.cleaned_data.get("my_photo")
  169. if remove and not new_file:
  170. try:
  171. if instance.my_photo:
  172. instance.my_photo.delete(save=False)
  173. except Exception:
  174. pass
  175. instance.my_photo = None
  176. if commit:
  177. instance.save()
  178. return instance
  179. # Weighing -------------------------------------------------------------------
  180. class WeighTicketForm(forms.Form):
  181. ticket_number = forms.CharField(max_length=64, required=False)
  182. gross_weight = forms.DecimalField(max_digits=12, decimal_places=3)
  183. tare_weight = forms.DecimalField(max_digits=12, decimal_places=3)
  184. net_weight = forms.DecimalField(max_digits=12, decimal_places=3, required=False, help_text="Auto-calculated if left blank")
  185. unit = forms.ChoiceField(choices=(), required=True)
  186. def __init__(self, *args, **kwargs):
  187. from .models import Material
  188. super().__init__(*args, **kwargs)
  189. self.fields["unit"].choices = Material.UNIT_CHOICES
  190. def clean(self):
  191. cleaned = super().clean()
  192. gross = cleaned.get("gross_weight")
  193. tare = cleaned.get("tare_weight")
  194. net = cleaned.get("net_weight")
  195. if gross is not None and tare is not None and net is None:
  196. cleaned["net_weight"] = (Decimal(gross) - Decimal(tare)).quantize(Decimal("0.001"))
  197. return cleaned
  198. class WeighLineForm(forms.Form):
  199. material = forms.ModelChoiceField(queryset=Material.objects.none())
  200. quantity = forms.DecimalField(max_digits=12, decimal_places=3)
  201. unit = forms.ChoiceField(choices=(), required=True)
  202. def __init__(self, *args, **kwargs):
  203. org = kwargs.pop("organization", None)
  204. super().__init__(*args, **kwargs)
  205. from .models import Material as Mat
  206. self.fields["unit"].choices = Mat.UNIT_CHOICES
  207. # Scope materials by organization if provided
  208. if org is not None:
  209. self.fields["material"].queryset = Material.objects.filter(organization=org).order_by("name")
  210. else:
  211. self.fields["material"].queryset = Material.objects.all().order_by("name")
  212. class MaterialEmissionFactorForm(forms.ModelForm):
  213. class Meta:
  214. model = MaterialEmissionFactor
  215. fields = [
  216. "organization",
  217. "material",
  218. "unit",
  219. "kgco2e_per_unit",
  220. "source",
  221. "effective_from",
  222. "effective_to",
  223. ]
  224. class PickupItemForm(forms.Form):
  225. material = forms.ModelChoiceField(queryset=Material.objects.none())
  226. estimated_qty = forms.DecimalField(max_digits=12, decimal_places=3, required=False)
  227. unit = forms.ChoiceField(choices=(), required=True)
  228. def __init__(self, *args, **kwargs):
  229. org = kwargs.pop("organization", None)
  230. super().__init__(*args, **kwargs)
  231. from .models import Material as Mat
  232. self.fields["unit"].choices = Mat.UNIT_CHOICES
  233. if org is not None:
  234. self.fields["material"].queryset = Material.objects.filter(organization=org).order_by("name")
  235. else:
  236. self.fields["material"].queryset = Material.objects.all().order_by("name")
  237. class WeighTicketWeightsForm(forms.Form):
  238. gross_weight = forms.DecimalField(max_digits=12, decimal_places=3)
  239. tare_weight = forms.DecimalField(max_digits=12, decimal_places=3)
  240. unit = forms.ChoiceField(choices=(), required=True)
  241. def __init__(self, *args, **kwargs):
  242. super().__init__(*args, **kwargs)
  243. from .models import Material as Mat
  244. self.fields["unit"].choices = Mat.UNIT_CHOICES
  245. def clean(self):
  246. cleaned = super().clean()
  247. g = cleaned.get("gross_weight")
  248. t = cleaned.get("tare_weight")
  249. if g is not None and t is not None and g < t:
  250. self.add_error("gross_weight", "Gross must be greater than or equal to tare")
  251. return cleaned