from django import forms from django.core.exceptions import ValidationError from django.contrib.auth import get_user_model from decimal import Decimal from django.utils import timezone from django.contrib.contenttypes.models import ContentType from .models import ( MaterialCategory, Material, MaterialImage, ProvidedService, Customer, CustomerSite, MaterialEmissionFactor, PickupItem, ) from markdownfield.widgets import MDEWidget from orgs.models import UserProfile class MaterialCategoryForm(forms.ModelForm): class Meta: model = MaterialCategory fields = ["organization", "name"] class MultiFileInput(forms.ClearableFileInput): allow_multiple_selected = True class MultiImageField(forms.Field): widget = MultiFileInput def __init__(self, *args, **kwargs): kwargs.setdefault("required", False) super().__init__(*args, **kwargs) def to_python(self, data): return data def validate(self, value): # Basic required check; skip per-file validation here if self.required and not value: raise ValidationError("This field is required.") class MaterialForm(forms.ModelForm): images = MultiImageField(help_text="Upload one or more sample images (optional)") class Meta: model = Material fields = ["organization", "category", "name", "code", "default_unit", "images"] def save(self, commit=True): instance = super().save(commit=commit) files = self.files.getlist("images") if hasattr(self, "files") else [] if commit and files: # Instance has a PK; we can create images now order_start = instance.images.count() for i, f in enumerate(files): MaterialImage.objects.create(material=instance, image=f, display_order=order_start + i) else: # Defer image saving until caller completes save self._pending_images = files return instance def save_images(self, instance: Material | None = None): """Persist any pending images after the Material has been saved.""" if not hasattr(self, "_pending_images"): return target = instance or getattr(self, "instance", None) if not target or not getattr(target, "pk", None): return order_start = target.images.count() for i, f in enumerate(self._pending_images or []): MaterialImage.objects.create(material=target, image=f, display_order=order_start + i) # Clear pending list self._pending_images = [] class CustomerForm(forms.ModelForm): class Meta: model = Customer fields = ["organization", "name", "email", "phone", "billing_address", "price_list"] class CustomerSiteForm(forms.ModelForm): class Meta: model = CustomerSite fields = ["customer", "name", "address", "contact_name", "contact_phone", "contact_email"] class ProvidedServiceForm(forms.ModelForm): class Meta: model = ProvidedService fields = ["title", "description", "body", "display_order", "is_enabled"] widgets = { "description": forms.Textarea(attrs={"rows": 3}), "body": MDEWidget(), } # Operational forms ---------------------------------------------------------- User = get_user_model() class PickupAssignForm(forms.Form): driver = forms.ModelChoiceField(queryset=User.objects.all(), required=True, label="Assign Driver") class PickupStatusForm(forms.Form): status = forms.ChoiceField(choices=(), required=True, label="Set Status") def __init__(self, *args, **kwargs): from .models import PickupOrder super().__init__(*args, **kwargs) self.fields["status"].choices = PickupOrder.STATUS_CHOICES class PaymentForm(forms.Form): amount = forms.DecimalField(max_digits=14, decimal_places=2) received_at = forms.DateTimeField(required=False, initial=timezone.now) reference = forms.CharField(max_length=128, required=False) class DocumentForm(forms.Form): organization = forms.ModelChoiceField(queryset=None) file = forms.FileField() kind = forms.CharField(max_length=64, required=False) content_type = forms.ModelChoiceField(queryset=ContentType.objects.all(), required=True, label="Attach To Model") object_id = forms.IntegerField(required=True, label="Object ID") def __init__(self, *args, **kwargs): from orgs.models import Organization as Org super().__init__(*args, **kwargs) self.fields["organization"].queryset = Org.objects.all() # User management ------------------------------------------------------------ class UserCreateForm(forms.Form): username = forms.CharField(max_length=150) email = forms.EmailField(required=False) first_name = forms.CharField(max_length=150, required=False) last_name = forms.CharField(max_length=150, required=False) role = forms.ChoiceField(choices=UserProfile.ROLE_CHOICES) password1 = forms.CharField(widget=forms.PasswordInput) password2 = forms.CharField(widget=forms.PasswordInput) def clean_username(self): User = get_user_model() username = self.cleaned_data["username"].strip() if User.objects.filter(username__iexact=username).exists(): raise forms.ValidationError("Username already taken") return username def clean(self): cleaned = super().clean() p1 = cleaned.get("password1") p2 = cleaned.get("password2") if p1 and p2 and p1 != p2: self.add_error("password2", "Passwords do not match") return cleaned class UserEditForm(forms.Form): email = forms.EmailField(required=False) first_name = forms.CharField(max_length=150, required=False) last_name = forms.CharField(max_length=150, required=False) role = forms.ChoiceField(choices=UserProfile.ROLE_CHOICES) is_active = forms.BooleanField(required=False, label="Active") password1 = forms.CharField(widget=forms.PasswordInput, required=False) password2 = forms.CharField(widget=forms.PasswordInput, required=False) def clean(self): cleaned = super().clean() p1 = cleaned.get("password1") p2 = cleaned.get("password2") if (p1 or p2) and p1 != p2: self.add_error("password2", "Passwords do not match") return cleaned class UserProfilePhotoForm(forms.ModelForm): remove_photo = forms.BooleanField(label="Remove current photo", required=False) class Meta: model = UserProfile fields = ["my_photo", "remove_photo"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["my_photo"].widget = forms.ClearableFileInput(attrs={ "accept": "image/*", "class": "hidden", "id": "id_my_photo", }) def clean_my_photo(self): f = self.cleaned_data.get("my_photo") if not f: return f content_type = getattr(f, "content_type", "") if content_type and not content_type.startswith("image/"): raise ValidationError("Please upload an image file.") max_bytes = 5 * 1024 * 1024 if getattr(f, "size", 0) and f.size > max_bytes: raise ValidationError("Image too large (max 5MB).") return f def save(self, commit: bool = True): instance: UserProfile = super().save(commit=False) remove = self.cleaned_data.get("remove_photo", False) new_file = self.cleaned_data.get("my_photo") if remove and not new_file: try: if instance.my_photo: instance.my_photo.delete(save=False) except Exception: pass instance.my_photo = None if commit: instance.save() return instance # Weighing ------------------------------------------------------------------- class WeighTicketForm(forms.Form): ticket_number = forms.CharField(max_length=64, required=False) gross_weight = forms.DecimalField(max_digits=12, decimal_places=3) tare_weight = forms.DecimalField(max_digits=12, decimal_places=3) net_weight = forms.DecimalField(max_digits=12, decimal_places=3, required=False, help_text="Auto-calculated if left blank") unit = forms.ChoiceField(choices=(), required=True) def __init__(self, *args, **kwargs): from .models import Material super().__init__(*args, **kwargs) self.fields["unit"].choices = Material.UNIT_CHOICES def clean(self): cleaned = super().clean() gross = cleaned.get("gross_weight") tare = cleaned.get("tare_weight") net = cleaned.get("net_weight") if gross is not None and tare is not None and net is None: cleaned["net_weight"] = (Decimal(gross) - Decimal(tare)).quantize(Decimal("0.001")) return cleaned class WeighLineForm(forms.Form): material = forms.ModelChoiceField(queryset=Material.objects.none()) quantity = forms.DecimalField(max_digits=12, decimal_places=3) unit = forms.ChoiceField(choices=(), required=True) def __init__(self, *args, **kwargs): org = kwargs.pop("organization", None) super().__init__(*args, **kwargs) from .models import Material as Mat self.fields["unit"].choices = Mat.UNIT_CHOICES # Scope materials by organization if provided if org is not None: self.fields["material"].queryset = Material.objects.filter(organization=org).order_by("name") else: self.fields["material"].queryset = Material.objects.all().order_by("name") class MaterialEmissionFactorForm(forms.ModelForm): class Meta: model = MaterialEmissionFactor fields = [ "organization", "material", "unit", "kgco2e_per_unit", "source", "effective_from", "effective_to", ] class PickupItemForm(forms.Form): material = forms.ModelChoiceField(queryset=Material.objects.none()) estimated_qty = forms.DecimalField(max_digits=12, decimal_places=3, required=False) unit = forms.ChoiceField(choices=(), required=True) def __init__(self, *args, **kwargs): org = kwargs.pop("organization", None) super().__init__(*args, **kwargs) from .models import Material as Mat self.fields["unit"].choices = Mat.UNIT_CHOICES if org is not None: self.fields["material"].queryset = Material.objects.filter(organization=org).order_by("name") else: self.fields["material"].queryset = Material.objects.all().order_by("name") class WeighTicketWeightsForm(forms.Form): gross_weight = forms.DecimalField(max_digits=12, decimal_places=3) tare_weight = forms.DecimalField(max_digits=12, decimal_places=3) unit = forms.ChoiceField(choices=(), required=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) from .models import Material as Mat self.fields["unit"].choices = Mat.UNIT_CHOICES def clean(self): cleaned = super().clean() g = cleaned.get("gross_weight") t = cleaned.get("tare_weight") if g is not None and t is not None and g < t: self.add_error("gross_weight", "Gross must be greater than or equal to tare") return cleaned