diff --git a/inventory/forms.py b/inventory/forms.py index 70dbc8ff..d2bd4295 100644 --- a/inventory/forms.py +++ b/inventory/forms.py @@ -16,7 +16,9 @@ from django_ledger.forms.invoice import ( ) from django_ledger.forms.bill import BillModelCreateForm as BillModelCreateFormBase -from django_ledger.forms.journal_entry import JournalEntryModelCreateForm as JournalEntryModelCreateFormBase +from django_ledger.forms.journal_entry import ( + JournalEntryModelCreateForm as JournalEntryModelCreateFormBase, +) from .models import ( Dealer, @@ -45,7 +47,7 @@ from .models import ( Customer, Organization, DealerSettings, - Tasks + Tasks, ) from django_ledger import models as ledger_models from django.forms import ( @@ -60,11 +62,12 @@ User = get_user_model() class SaudiPhoneNumberField(forms.CharField): def __init__(self, *args, **kwargs): - kwargs.setdefault('min_length', 10) - kwargs.setdefault('max_length', 13) + kwargs.setdefault("min_length", 10) + kwargs.setdefault("max_length", 13) super().__init__(*args, **kwargs) self.validators.append(SaudiPhoneNumberValidator()) + class AdditionalServiceForm(forms.ModelForm): """ A form used for creating and updating instances of the @@ -81,10 +84,12 @@ class AdditionalServiceForm(forms.ModelForm): model that this form includes. :type Meta.fields: list """ + class Meta: model = AdditionalServices fields = ["name", "price", "description", "taxable", "uom"] + class StaffForm(forms.ModelForm): """ Represents a form for managing Staff entities, including associated user email updates @@ -100,6 +105,7 @@ class StaffForm(forms.ModelForm): :ivar service_offered: Collection of services offered by the staff. :type service_offered: forms.ModelMultipleChoiceField """ + email = forms.EmailField( required=True, label=_("Email"), @@ -110,16 +116,20 @@ class StaffForm(forms.ModelForm): label=_("Services Offered"), widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check-input"}), queryset=Service.objects.all(), - required=False,) + required=False, + ) phone_number = SaudiPhoneNumberField( required=False, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': _('Phone Number'), - 'id': 'phone' - }), - label=_('Phone Number'), + widget=forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": _("Phone Number"), + "id": "phone", + } + ), + label=_("Phone Number"), ) + class Meta: model = Staff fields = ["name", "arabic_name", "phone_number", "staff_type"] @@ -150,7 +160,9 @@ class DealerForm(forms.ModelForm): :type logo: File """ - phone_number = SaudiPhoneNumberField(label=_('Phone Number')) + + phone_number = SaudiPhoneNumberField(label=_("Phone Number")) + class Meta: model = Dealer fields = [ @@ -165,12 +177,12 @@ class DealerForm(forms.ModelForm): class CustomerForm(forms.ModelForm): - phone_number = SaudiPhoneNumberField(label=_('Phone Number')) + phone_number = SaudiPhoneNumberField(label=_("Phone Number")) class Meta: model = Customer fields = [ - 'title', + "title", "first_name", "last_name", "email", @@ -178,19 +190,31 @@ class CustomerForm(forms.ModelForm): "national_id", "dob", "address", - 'image', + "image", ] widgets = { - 'title': forms.Select(attrs={'class': 'form-control form-control-sm'}), - 'first_name': forms.TextInput(attrs={'class': 'form-control form-control-sm'}), - 'last_name': forms.TextInput(attrs={'class': 'form-control form-control-sm'}), - 'email': forms.EmailInput(attrs={'class': 'form-control form-control-sm'}), - 'phone_number': forms.TextInput(attrs={'class': 'form-control form-control-sm'}), - 'national_id': forms.TextInput(attrs={'class': 'form-control form-control-sm'}), - 'dob': DateInput(attrs={'class': 'form-control form-control-sm', 'type': 'date'}), - 'address': forms.Textarea(attrs={'class': 'form-control form-control-sm'}), - 'image': forms.FileInput(attrs={'class': 'form-control form-control-sm'}), + "title": forms.Select(attrs={"class": "form-control form-control-sm"}), + "first_name": forms.TextInput( + attrs={"class": "form-control form-control-sm"} + ), + "last_name": forms.TextInput( + attrs={"class": "form-control form-control-sm"} + ), + "email": forms.EmailInput(attrs={"class": "form-control form-control-sm"}), + "phone_number": forms.TextInput( + attrs={"class": "form-control form-control-sm"} + ), + "national_id": forms.TextInput( + attrs={"class": "form-control form-control-sm"} + ), + "dob": DateInput( + attrs={"class": "form-control form-control-sm", "type": "date"} + ), + "address": forms.Textarea(attrs={"class": "form-control form-control-sm"}), + "image": forms.FileInput(attrs={"class": "form-control form-control-sm"}), } + + # class CustomerForm(forms.Form): # """ # Represents a form for collecting customer information. @@ -264,10 +288,21 @@ class OrganizationForm(forms.ModelForm): :ivar logo: Optional field to upload the logo of the organization. :type logo: forms.ImageField """ - phone_number = SaudiPhoneNumberField(label=_('Phone Number'),required=True) + + phone_number = SaudiPhoneNumberField(label=_("Phone Number"), required=True) + class Meta: model = Organization - fields = ["name","arabic_name","email","phone_number","crn","vrn","address","logo"] + fields = [ + "name", + "arabic_name", + "email", + "phone_number", + "crn", + "vrn", + "address", + "logo", + ] class CarForm( @@ -289,6 +324,7 @@ class CarForm( :ivar Meta.widgets: Defines the widgets for specific fields. :type Meta.widgets: dict """ + class Meta: model = Car fields = [ @@ -346,6 +382,7 @@ class CarUpdateForm(forms.ModelForm, AddClassMixin): uses a `DateTimeInput` configured with `datetime-local` attributes, while "remarks" uses a `Textarea` with a specific row configuration. """ + class Meta: model = Car fields = [ @@ -379,6 +416,7 @@ class CarFinanceForm(forms.ModelForm): :ivar additional_finances: A field that allows selecting multiple additional services associated with a car finance application. """ + additional_finances = forms.ModelMultipleChoiceField( queryset=AdditionalServices.objects.all(), widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check-input"}), @@ -414,6 +452,7 @@ class CarLocationForm(forms.ModelForm): specified fields, and custom widget configurations. :type Meta: class """ + class Meta: model = CarLocation fields = ["showroom", "description"] @@ -437,6 +476,7 @@ class CarTransferForm(forms.ModelForm): :ivar widgets: Custom widgets applied to the form fields. :type widgets: dict[str, Any] """ + class Meta: model = CarTransfer fields = ["car", "to_dealer", "remarks"] @@ -461,6 +501,7 @@ class CustomCardForm(forms.ModelForm): includes a specific label for localized presentation. :type custom_date: forms.DateTimeField """ + custom_date = forms.DateTimeField( widget=forms.DateInput(attrs={"type": "date"}), label=_("Custom Date"), @@ -490,6 +531,7 @@ class CarRegistrationForm(forms.ModelForm): :ivar Meta.widgets: Custom widget configurations for specific fields. :type Meta.widgets: dict """ + class Meta: model = CarRegistration fields = ["plate_number", "text1", "text2", "text3", "registration_date"] @@ -512,7 +554,8 @@ class VendorForm(forms.ModelForm): :ivar Meta: Inner class to define metadata for the Vendor form. :type Meta: Type[VendorForm.Meta] """ - phone_number = SaudiPhoneNumberField(label=_('Phone Number')) + + phone_number = SaudiPhoneNumberField(label=_("Phone Number")) contact_person = forms.CharField(label=_("Contact Person")) class Meta: @@ -546,30 +589,31 @@ class CarColorsForm(forms.ModelForm): to include in the form, namely ``exterior`` and ``interior``. :type Meta.fields: list """ + class Meta: model = CarColors fields = ["exterior", "interior"] def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + super().__init__(*args, **kwargs) - self.fields["exterior"].queryset = ExteriorColors.objects.all() - self.fields["exterior"].widget = forms.RadioSelect( - attrs={"class": "form-check-input"} - ) - self.fields["exterior"].choices = [ - (color.id, f"{color.get_local_name}") - for color in ExteriorColors.objects.all().order_by("-name") - ] + self.fields["exterior"].queryset = ExteriorColors.objects.all() + self.fields["exterior"].widget = forms.RadioSelect( + attrs={"class": "form-check-input"} + ) + self.fields["exterior"].choices = [ + (color.id, f"{color.get_local_name}") + for color in ExteriorColors.objects.all().order_by("-name") + ] - self.fields["interior"].queryset = InteriorColors.objects.all() - self.fields["interior"].widget = forms.RadioSelect( - attrs={"class": "form-check-input"} - ) - self.fields["interior"].choices = [ - (color.id, f"{color.get_local_name}") - for color in InteriorColors.objects.all().order_by("-name") - ] + self.fields["interior"].queryset = InteriorColors.objects.all() + self.fields["interior"].widget = forms.RadioSelect( + attrs={"class": "form-check-input"} + ) + self.fields["interior"].choices = [ + (color.id, f"{color.get_local_name}") + for color in InteriorColors.objects.all().order_by("-name") + ] def clean(self): cleaned_data = super().clean() @@ -599,7 +643,8 @@ class RepresentativeForm(forms.ModelForm): :ivar Meta.fields: The fields from the model to include in the form. :type Meta.fields: list of str """ - phone_number = SaudiPhoneNumberField(label=_('Phone Number')) + + phone_number = SaudiPhoneNumberField(label=_("Phone Number")) class Meta: model = Representative @@ -632,6 +677,7 @@ class CarSelectionTable(tables.Table): model, fields, and template. :type Meta: django_tables2.tables.Table.Meta """ + select = tables.CheckBoxColumn(accessor="pk", orderable=False) class Meta: @@ -666,8 +712,9 @@ class WizardForm1(forms.Form): This is a required checkbox input. :type terms: django.forms.BooleanField """ + hx_attrs = { - "hx-post":"", + "hx-post": "", "hx-target": "#wizardValidationForm1", "hx-select": "#wizardValidationForm1", "hx-trigger": "blur delay:500ms", @@ -698,7 +745,7 @@ class WizardForm1(forms.Form): "required": "required", # **hx_attrs }, - render_value=True + render_value=True, ), error_messages={ "required": _("This field is required."), @@ -715,7 +762,7 @@ class WizardForm1(forms.Form): "required": "required", # **hx_attrs }, - render_value=True + render_value=True, ), error_messages={ "required": _("This field is required."), @@ -737,7 +784,6 @@ class WizardForm1(forms.Form): }, ) - def clean_email(self): email = self.cleaned_data.get("email") if email: @@ -777,6 +823,7 @@ class WizardForm2(forms.Form): and region-specific configuration. :type phone_number: PhoneNumberField """ + name = forms.CharField( label=_("Name"), widget=forms.TextInput( @@ -819,7 +866,7 @@ class WizardForm2(forms.Form): # }, # required=True, # ) - phone_number = SaudiPhoneNumberField(label=_('Phone Number')) + phone_number = SaudiPhoneNumberField(label=_("Phone Number")) class WizardForm3(forms.Form): @@ -841,6 +888,7 @@ class WizardForm3(forms.Form): with validation enforcing the field to be required. :type address: django.forms.fields.CharField """ + # CRN field with max length of 10 crn = forms.CharField( label=_("CRN"), @@ -891,6 +939,7 @@ class WizardForm3(forms.Form): }, ) + class ItemForm(forms.Form): """ A form for handling item-related inputs in the application. @@ -906,6 +955,7 @@ class ItemForm(forms.Form): selected item. :type quantity: DecimalField """ + item = forms.ModelChoiceField( queryset=ledger_models.ItemModel.objects.all(), label=_("Item"), @@ -935,6 +985,7 @@ class PaymentForm(forms.Form): :ivar payment_date: The date on which the payment is made. :type payment_date: DateField """ + invoice = forms.ModelChoiceField( queryset=ledger_models.InvoiceModel.objects.all(), label=_("Invoice"), @@ -994,6 +1045,7 @@ class EmailForm(forms.Form): :ivar to_email: The recipient's email address. :type to_email: forms.EmailField """ + subject = forms.CharField(max_length=255) message = forms.CharField(widget=forms.Textarea) from_email = forms.EmailField() @@ -1016,7 +1068,8 @@ class LeadForm(forms.ModelForm): options are displayed until a car make is selected. :type id_car_model: ModelChoiceField """ - phone_number = SaudiPhoneNumberField(label=_('Phone Number')) + + phone_number = SaudiPhoneNumberField(label=_("Phone Number")) # email = forms.EmailField( # label=_("Email"), @@ -1064,32 +1117,32 @@ class LeadForm(forms.ModelForm): "last_name", "email", "phone_number", - "lead_type", "address", + "lead_type", "id_car_make", "id_car_model", - "crn", - "vrn", - "year", - "salary", "source", "channel", "staff", - "priority", ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def filter_qs(self,**kwargs): - dealer = kwargs['dealer'] - dealer_make_list = DealersMake.objects.filter(dealer=dealer).values_list("car_make",flat=True) + def filter_qs(self, **kwargs): + dealer = kwargs["dealer"] + dealer_make_list = DealersMake.objects.filter(dealer=dealer).values_list( + "car_make", flat=True + ) if "id_car_make" in self.fields: - queryset = self.fields["id_car_make"].queryset.filter(is_sa_import=True,pk__in=dealer_make_list) + queryset = self.fields["id_car_make"].queryset.filter( + is_sa_import=True, pk__in=dealer_make_list + ) self.fields["id_car_make"].choices = [ (obj.id_car_make, obj.get_local_name()) for obj in queryset ] + class ScheduleForm(forms.ModelForm): """ Represents a form for scheduling events, extending ModelForm to bind to the @@ -1103,12 +1156,20 @@ class ScheduleForm(forms.ModelForm): 'datetime-local' HTML widget. :type scheduled_at: DateTimeField """ + scheduled_at = forms.DateTimeField( widget=DateTimeInput(attrs={"type": "datetime-local"}) ) + class Meta: model = Schedule - fields = ["purpose", "scheduled_type", "scheduled_at", "duration", "notes",] + fields = [ + "purpose", + "scheduled_type", + "scheduled_at", + "duration", + "notes", + ] class NoteForm(forms.ModelForm): @@ -1125,6 +1186,7 @@ class NoteForm(forms.ModelForm): :ivar Meta.fields: The fields to include in the form. :type Meta.fields: list """ + class Meta: model = Notes fields = ["note"] @@ -1143,7 +1205,11 @@ class ActivityForm(forms.ModelForm): associated with the form and the fields it comprises. :type Meta: type """ - activity_type = forms.ChoiceField(choices=[("call", "Call"), ("email", "Email"), ("meeting", "Meeting")]) + + activity_type = forms.ChoiceField( + choices=[("call", "Call"), ("email", "Email"), ("meeting", "Meeting")] + ) + class Meta: model = Activity fields = ["activity_type", "notes"] @@ -1164,34 +1230,48 @@ class OpportunityForm(forms.ModelForm): :ivar Meta.fields: List of fields from the model included in the form. :type Meta.fields: list """ - closing_date = forms.DateField( - label=_("Expected Closing Date"), - widget=forms.DateInput(attrs={"type": "date"}) + + expected_close_date = forms.DateField( + label=_("Expected Closing Date"), widget=forms.DateInput(attrs={"type": "date"}) ) probability = forms.IntegerField( label=_("Probability (%)"), - widget=forms.NumberInput(attrs={ - 'type': 'range', - 'min': '0', - 'max': '100', - 'step': '1', - 'class': 'form-range', - 'oninput': 'this.nextElementSibling.value = this.value' - }), - initial=50 # Default value + widget=forms.NumberInput( + attrs={ + "type": "range", + "min": "0", + "max": "100", + "step": "1", + "class": "form-range", + "oninput": "this.nextElementSibling.value = this.value", + } + ), + initial=50, # Default value ) class Meta: model = Opportunity - fields = ["lead", "car", "stage", "probability", "expected_revenue", "closing_date"] - + fields = [ + "lead", + "car", + "stage", + "probability", + "amount", + "expected_revenue", + "expected_close_date", + ] + widgets = { + "expected_revenue": forms.NumberInput(attrs={"readonly": "readonly"}), + } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Add a visible number input to display the current value - self.fields['probability'].widget.attrs['class'] = 'd-none' # Hide the default input + self.fields["probability"].widget.attrs["class"] = ( + "d-none" # Hide the default input + ) if self.instance and self.instance.pk: - self.fields['probability'].initial = self.instance.probability + self.fields["probability"].initial = self.instance.probability class InvoiceModelCreateForm(InvoiceModelCreateFormBase): @@ -1207,6 +1287,7 @@ class InvoiceModelCreateForm(InvoiceModelCreateFormBase): the field names and values are their respective Field objects. :type fields: dict """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1237,6 +1318,7 @@ class BillModelCreateForm(BillModelCreateFormBase): :ivar fields: A dictionary of all fields used in the form. :type fields: dict """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1244,6 +1326,7 @@ class BillModelCreateForm(BillModelCreateFormBase): self.fields["prepaid_account"].widget = forms.HiddenInput() self.fields["unearned_account"].widget = forms.HiddenInput() + class SaleOrderForm(forms.ModelForm): """ Represents a form for creating or updating sales orders. @@ -1261,11 +1344,23 @@ class SaleOrderForm(forms.ModelForm): for fields, such as customizing their appearance. :type Meta.widgets: Dict[str, Any] """ + class Meta: model = SaleOrder - fields = ["estimate", "payment_method", "comments"] + fields = [ + "estimate", + "payment_method", + "opportunity", + "agreed_price", + "down_payment_amount", + "loan_amount", + "expected_delivery_date", + "comments", + "status" + ] widgets = { "comments": forms.Textarea(attrs={"rows": 3}), + "expected_delivery_date": forms.DateInput(attrs={"type": "date"}), } @@ -1273,6 +1368,7 @@ class EstimateModelCreateForm(forms.Form): title = forms.CharField(max_length=255) customer = forms.ModelChoiceField(queryset=Customer.objects.none()) + # class EstimateModelCreateForm(EstimateModelCreateFormBase): # """ # Defines the EstimateModelCreateForm class, which is used to create and manage @@ -1358,6 +1454,7 @@ class OpportunityStatusForm(forms.Form): widget configured with attributes to handle UI updates dynamically. :type stage: ChoiceField """ + status = forms.ChoiceField( label=_("Status"), choices=Status.choices, @@ -1390,6 +1487,7 @@ class OpportunityStatusForm(forms.Form): required=True, ) + class GroupForm(forms.ModelForm): """ A form for creating and updating CustomGroup objects. @@ -1404,10 +1502,12 @@ class GroupForm(forms.ModelForm): :ivar fields: List of fields to include in the form. In this case, only the `name` field. :type fields: List[str] """ + class Meta: model = CustomGroup fields = ["name"] + class PermissionForm(forms.ModelForm): """ Represents a form for managing permissions using a multiple-choice field. @@ -1421,19 +1521,33 @@ class PermissionForm(forms.ModelForm): related to specific app labels (`inventory` and `django_ledger`). :type name: ModelMultipleChoiceField """ + name = forms.ModelMultipleChoiceField( - queryset=cache.get('permissions_queryset', Permission.objects.filter(content_type__app_label__in=["inventory","django_ledger"])), + queryset=cache.get( + "permissions_queryset", + Permission.objects.filter( + content_type__app_label__in=["inventory", "django_ledger"] + ), + ), widget=forms.CheckboxSelectMultiple(), - required=True + required=True, ) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - cache.set('permissions_queryset', Permission.objects.filter(content_type__app_label__in=["inventory","django_ledger"]), 60*60) + cache.set( + "permissions_queryset", + Permission.objects.filter( + content_type__app_label__in=["inventory", "django_ledger"] + ), + 60 * 60, + ) class Meta: model = Permission fields = ["name"] + class UserGroupForm(forms.ModelForm): """ Represents a form for selecting user groups. @@ -1446,15 +1560,18 @@ class UserGroupForm(forms.ModelForm): :ivar name: A field for selecting multiple groups using checkboxes. :type name: ModelMultipleChoiceField """ + name = forms.ModelMultipleChoiceField( - queryset= CustomGroup.objects.all(), + queryset=CustomGroup.objects.all(), widget=forms.CheckboxSelectMultiple(), - required=True + required=True, ) + class Meta: model = CustomGroup fields = ["name"] + class DealerSettingsForm(forms.ModelForm): """ Represents a form for managing DealerSettings. @@ -1469,10 +1586,12 @@ class DealerSettingsForm(forms.ModelForm): that all model fields should be included. :type fields: str """ + class Meta: model = DealerSettings fields = "__all__" + class LeadTransferForm(forms.Form): """ Represents a form for transferring leads between staff members. @@ -1485,7 +1604,8 @@ class LeadTransferForm(forms.Form): :ivar transfer_to: Field for selecting the staff to transfer the lead to. :type transfer_to: ModelChoiceField """ - transfer_to = forms.ModelChoiceField(label="to",queryset=Staff.objects.all()) + + transfer_to = forms.ModelChoiceField(label="to", queryset=Staff.objects.all()) class DealersMakeForm(forms.Form): @@ -1500,11 +1620,12 @@ class DealersMakeForm(forms.Form): is used to filter and save car makes for a specific dealer. :type dealer: Dealer or None """ + car_makes = forms.ModelMultipleChoiceField( queryset=CarMake.objects.filter(is_sa_import=True), widget=forms.CheckboxSelectMultiple(attrs={"class": "car-makes-grid"}), required=True, - label=_("Select Car Makes") + label=_("Select Car Makes"), ) def __init__(self, *args, **kwargs): @@ -1532,6 +1653,7 @@ class JournalEntryModelCreateForm(JournalEntryModelCreateFormBase): :ivar bar: Description of bar attribute. :type bar: int """ + pass @@ -1549,10 +1671,10 @@ class PlanPricingForm(forms.ModelForm): that all model fields should be included. :type fields: str """ + class Meta: model = PlanPricing - fields = ["plan","pricing", "price"] - + fields = ["plan", "pricing", "price"] class CreditCardField(forms.CharField): @@ -1560,31 +1682,34 @@ class CreditCardField(forms.CharField): value = super().clean(value) if value: # Remove all non-digit characters - cleaned_value = ''.join(c for c in value if c.isdigit()) + cleaned_value = "".join(c for c in value if c.isdigit()) # Validate using Luhn algorithm if not Luhn.check_luhn(cleaned_value): - raise forms.ValidationError(_("Please enter a valid credit card number")) + raise forms.ValidationError( + _("Please enter a valid credit card number") + ) # Add basic card type detection (optional) - if cleaned_value.startswith('4'): - self.card_type = 'visa' - elif cleaned_value.startswith(('51', '52', '53', '54', '55')): - self.card_type = 'mastercard' - elif cleaned_value.startswith(('34', '37')): - self.card_type = 'amex' + if cleaned_value.startswith("4"): + self.card_type = "visa" + elif cleaned_value.startswith(("51", "52", "53", "54", "55")): + self.card_type = "mastercard" + elif cleaned_value.startswith(("34", "37")): + self.card_type = "amex" else: - self.card_type = 'unknown' + self.card_type = "unknown" return value return value + class ExpiryDateField(forms.CharField): def clean(self, value): value = super().clean(value) if value: try: - month, year = value.split('/') + month, year = value.split("/") month = int(month.strip()) year = int(year.strip()) @@ -1600,14 +1725,19 @@ class ExpiryDateField(forms.CharField): current_year = datetime.now().year current_month = datetime.now().month - if year < current_year or (year == current_year and month < current_month): + if year < current_year or ( + year == current_year and month < current_month + ): raise forms.ValidationError(_("This card appears to be expired")) except (ValueError, AttributeError): - raise forms.ValidationError(_("Please enter a valid expiry date in MM/YY format")) + raise forms.ValidationError( + _("Please enter a valid expiry date in MM/YY format") + ) return value + class CVVField(forms.CharField): def clean(self, value): value = super().clean(value) @@ -1618,121 +1748,126 @@ class CVVField(forms.CharField): raise forms.ValidationError(_("CVV must be 3 or 4 digits")) return value + class PaymentPlanForm(forms.Form): # Customer Information first_name = forms.CharField( max_length=100, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': _('First Name'), - 'id': 'first-name' - }), - label=_('First Name') + widget=forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": _("First Name"), + "id": "first-name", + } + ), + label=_("First Name"), ) last_name = forms.CharField( max_length=100, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': _('Last Name'), - 'id': 'last-name' - }), - label=_('Last Name') + widget=forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": _("Last Name"), + "id": "last-name", + } + ), + label=_("Last Name"), ) email = forms.EmailField( - widget=forms.EmailInput(attrs={ - 'class': 'form-control', - 'placeholder': _('Email'), - 'id': 'email' - }), - label=_('Email Address') + widget=forms.EmailInput( + attrs={"class": "form-control", "placeholder": _("Email"), "id": "email"} + ), + label=_("Email Address"), ) - phone_number = SaudiPhoneNumberField(label=_('Phone Number')) + phone_number = SaudiPhoneNumberField(label=_("Phone Number")) # Credit Card Fields (not saved to database) card_number = CreditCardField( required=True, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': '1234 5678 9012 3456', - 'id': 'card-number', - - }), - label=_("Card Number") + widget=forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "1234 5678 9012 3456", + "id": "card-number", + } + ), + label=_("Card Number"), ) expiry_date = ExpiryDateField( required=True, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'MM/YY', - 'id': 'expiry', - - }), - label=_("Expiration Date") + widget=forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "MM/YY", + "id": "expiry", + } + ), + label=_("Expiration Date"), ) cvv = CVVField( required=True, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': '123', - 'id': 'cvv', - - }), - label=_("Security Code (CVV)") + widget=forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "123", + "id": "cvv", + } + ), + label=_("Security Code (CVV)"), ) card_name = forms.CharField( required=True, max_length=100, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'John Doe', - 'id': 'card-name', - - }), - label=_("Name on Card") + widget=forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "John Doe", + "id": "card-name", + } + ), + label=_("Name on Card"), ) # Terms and conditions terms = forms.BooleanField( required=True, - widget=forms.CheckboxInput(attrs={ - 'class': 'form-check-input', - 'id': 'terms' - }), - label=_('I agree to the Terms and Conditions'), - error_messages={ - 'required': _('You must accept the terms and conditions') - } + widget=forms.CheckboxInput(attrs={"class": "form-check-input", "id": "terms"}), + label=_("I agree to the Terms and Conditions"), + error_messages={"required": _("You must accept the terms and conditions")}, ) + def clean(self): cleaned_data = super().clean() - payment_method = self.data.get('payment-method') + payment_method = self.data.get("payment-method") - if payment_method == 'credit-card': - if not all([ - cleaned_data.get('card_number'), - cleaned_data.get('expiry_date'), - cleaned_data.get('cvv'), - cleaned_data.get('card_name') - ]): + if payment_method == "credit-card": + if not all( + [ + cleaned_data.get("card_number"), + cleaned_data.get("expiry_date"), + cleaned_data.get("cvv"), + cleaned_data.get("card_name"), + ] + ): raise forms.ValidationError("Please complete all credit card fields") return cleaned_data def __init__(self, *args, **kwargs): - user = kwargs.pop('user', None) + user = kwargs.pop("user", None) super().__init__(*args, **kwargs) if user and user.is_authenticated: # Pre-fill form with user data if available - self.fields['first_name'].initial = user.first_name - self.fields['last_name'].initial = user.last_name - self.fields['email'].initial = user.email + self.fields["first_name"].initial = user.first_name + self.fields["last_name"].initial = user.last_name + self.fields["email"].initial = user.email # class ActivityHistoryForm(forms.Form): @@ -1756,10 +1891,11 @@ class PaymentPlanForm(forms.Form): # label=_('Description') # ) + class StaffTaskForm(forms.ModelForm): class Meta: model = Tasks - fields = ['title','due_date' ,'description'] + fields = ["title", "due_date", "description"] widgets = { - 'due_date': forms.DateTimeInput(attrs={'type': 'date'}), - } \ No newline at end of file + "due_date": forms.DateTimeInput(attrs={"type": "date"}), + } diff --git a/inventory/migrations/0003_alter_saleorder_options_and_more.py b/inventory/migrations/0003_alter_saleorder_options_and_more.py new file mode 100644 index 00000000..1e914107 --- /dev/null +++ b/inventory/migrations/0003_alter_saleorder_options_and_more.py @@ -0,0 +1,125 @@ +# Generated by Django 5.1.7 on 2025-05-27 14:41 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0002_carfinance_is_sold'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='saleorder', + options={'ordering': ['-order_date'], 'verbose_name': 'Sales Order', 'verbose_name_plural': 'Sales Orders'}, + ), + migrations.RenameField( + model_name='saleorder', + old_name='created', + new_name='created_at', + ), + migrations.AddField( + model_name='saleorder', + name='actual_delivery_date', + field=models.DateTimeField(blank=True, help_text='The actual date and time the vehicle was delivered.', null=True), + ), + migrations.AddField( + model_name='saleorder', + name='agreed_price', + field=models.DecimalField(decimal_places=2, default=0, help_text='The final agreed-upon selling price of the vehicle.', max_digits=12), + preserve_default=False, + ), + migrations.AddField( + model_name='saleorder', + name='cancellation_reason', + field=models.TextField(blank=True, help_text='Reason for cancellation, if applicable.', null=True), + ), + migrations.AddField( + model_name='saleorder', + name='cancelled_date', + field=models.DateTimeField(blank=True, help_text='The date and time the order was cancelled, if applicable.', null=True), + ), + migrations.AddField( + model_name='saleorder', + name='car', + field=models.ForeignKey(default=1, help_text='The specific vehicle (VIN) being sold.', on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='inventory.car'), + preserve_default=False, + ), + migrations.AddField( + model_name='saleorder', + name='created_by', + field=models.ForeignKey(help_text='The user who created this sales order.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_sales_orders', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='saleorder', + name='customer', + field=models.ForeignKey(default=1, help_text='The customer making the purchase.', on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='inventory.customer'), + preserve_default=False, + ), + migrations.AddField( + model_name='saleorder', + name='down_payment_amount', + field=models.DecimalField(decimal_places=2, default=0.0, help_text='The initial payment made by the customer.', max_digits=12), + ), + migrations.AddField( + model_name='saleorder', + name='expected_delivery_date', + field=models.DateField(blank=True, help_text='The planned date for vehicle delivery.', null=True), + ), + migrations.AddField( + model_name='saleorder', + name='last_modified_by', + field=models.ForeignKey(help_text='The user who last modified this sales order.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_sales_orders', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='saleorder', + name='loan_amount', + field=models.DecimalField(decimal_places=2, default=0.0, help_text='The amount financed by a bank or third-party lender.', max_digits=12), + ), + migrations.AddField( + model_name='saleorder', + name='opportunity', + field=models.OneToOneField(default=1, help_text='The associated sales opportunity for this order.', on_delete=django.db.models.deletion.CASCADE, related_name='sales_order', to='inventory.opportunity'), + preserve_default=False, + ), + migrations.AddField( + model_name='saleorder', + name='order_date', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='The date and time the sales order was created.'), + ), + migrations.AddField( + model_name='saleorder', + name='remaining_balance', + field=models.DecimalField(decimal_places=2, default=0.0, help_text='The remaining amount due from the customer or financing.', max_digits=12), + ), + migrations.AddField( + model_name='saleorder', + name='status', + field=models.CharField(choices=[('PENDING_APPROVAL', 'Pending Approval'), ('APPROVED', 'Approved'), ('IN_FINANCING', 'In Financing'), ('PARTIALLY_PAID', 'Partially Paid'), ('FULLY_PAID', 'Fully Paid'), ('PENDING_DELIVERY', 'Pending Delivery'), ('DELIVERED', 'Delivered'), ('CANCELLED', 'Cancelled')], default='PENDING_APPROVAL', help_text='Current status of the sales order.', max_length=20), + ), + migrations.AddField( + model_name='saleorder', + name='total_paid_amount', + field=models.DecimalField(decimal_places=2, default=0.0, help_text='Sum of down payment, trade-in value, and loan amount received so far.', max_digits=12), + ), + migrations.AddField( + model_name='saleorder', + name='trade_in_value', + field=models.DecimalField(decimal_places=2, default=0.0, help_text='The value of any vehicle traded in by the customer.', max_digits=12), + ), + migrations.AddField( + model_name='saleorder', + name='trade_in_vehicle', + field=models.ForeignKey(blank=True, help_text='The vehicle traded in by the customer, if any.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='traded_in_on_orders', to='inventory.car'), + ), + migrations.AddField( + model_name='saleorder', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/inventory/migrations/0004_alter_saleorder_car.py b/inventory/migrations/0004_alter_saleorder_car.py new file mode 100644 index 00000000..1990da55 --- /dev/null +++ b/inventory/migrations/0004_alter_saleorder_car.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.7 on 2025-05-27 14:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0003_alter_saleorder_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='saleorder', + name='car', + field=models.ForeignKey(blank=True, help_text='The specific vehicle (VIN) being sold.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='inventory.car'), + ), + ] diff --git a/inventory/migrations/0005_alter_opportunity_stage.py b/inventory/migrations/0005_alter_opportunity_stage.py new file mode 100644 index 00000000..ef95a90a --- /dev/null +++ b/inventory/migrations/0005_alter_opportunity_stage.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-05-28 13:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0004_alter_saleorder_car'), + ] + + operations = [ + migrations.AlterField( + model_name='opportunity', + name='stage', + field=models.CharField(choices=[('qualification', 'Qualification'), ('test_drive', 'Test Drive'), ('quotation', 'Quotation'), ('negotiation', 'Negotiation'), ('financing', 'Financing'), ('closed_won', 'Closed Won'), ('closed_lost', 'Closed Lost'), ('on_hold', 'On Hold')], max_length=20, verbose_name='Stage'), + ), + ] diff --git a/inventory/migrations/0006_remove_opportunity_closing_date_and_more.py b/inventory/migrations/0006_remove_opportunity_closing_date_and_more.py new file mode 100644 index 00000000..01113f52 --- /dev/null +++ b/inventory/migrations/0006_remove_opportunity_closing_date_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 5.1.7 on 2025-05-28 13:16 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0005_alter_opportunity_stage'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='opportunity', + name='closing_date', + ), + migrations.AddField( + model_name='opportunity', + name='assigned_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_opportunities', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='opportunity', + name='expected_close_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='opportunity', + name='loss_reason', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='opportunity', + name='vehicle_of_interest_make', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AddField( + model_name='opportunity', + name='vehicle_of_interest_model', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/inventory/migrations/0007_alter_lead_status_alter_leadstatushistory_new_status_and_more.py b/inventory/migrations/0007_alter_lead_status_alter_leadstatushistory_new_status_and_more.py new file mode 100644 index 00000000..b7ec7515 --- /dev/null +++ b/inventory/migrations/0007_alter_lead_status_alter_leadstatushistory_new_status_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.7 on 2025-05-28 13:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0006_remove_opportunity_closing_date_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='lead', + name='status', + field=models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('qualified', 'Qualified'), ('unqualified', 'Unqualified'), ('converted', 'Converted')], db_index=True, default='new', max_length=50, verbose_name='Status'), + ), + migrations.AlterField( + model_name='leadstatushistory', + name='new_status', + field=models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('qualified', 'Qualified'), ('unqualified', 'Unqualified'), ('converted', 'Converted')], max_length=50, verbose_name='New Status'), + ), + migrations.AlterField( + model_name='leadstatushistory', + name='old_status', + field=models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('qualified', 'Qualified'), ('unqualified', 'Unqualified'), ('converted', 'Converted')], max_length=50, verbose_name='Old Status'), + ), + ] diff --git a/inventory/migrations/0008_remove_lead_address_remove_lead_crn_and_more.py b/inventory/migrations/0008_remove_lead_address_remove_lead_crn_and_more.py new file mode 100644 index 00000000..890d7f48 --- /dev/null +++ b/inventory/migrations/0008_remove_lead_address_remove_lead_crn_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.7 on 2025-05-28 13:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0007_alter_lead_status_alter_leadstatushistory_new_status_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='lead', + name='address', + ), + migrations.RemoveField( + model_name='lead', + name='crn', + ), + migrations.RemoveField( + model_name='lead', + name='priority', + ), + migrations.RemoveField( + model_name='lead', + name='salary', + ), + migrations.RemoveField( + model_name='lead', + name='vrn', + ), + migrations.RemoveField( + model_name='lead', + name='year', + ), + ] diff --git a/inventory/migrations/0009_lead_address_opportunity_crn_opportunity_priority_and_more.py b/inventory/migrations/0009_lead_address_opportunity_crn_opportunity_priority_and_more.py new file mode 100644 index 00000000..2e0a4043 --- /dev/null +++ b/inventory/migrations/0009_lead_address_opportunity_crn_opportunity_priority_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.7 on 2025-05-28 13:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0008_remove_lead_address_remove_lead_crn_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='lead', + name='address', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Address'), + ), + migrations.AddField( + model_name='opportunity', + name='crn', + field=models.CharField(blank=True, max_length=20, null=True, verbose_name='CRN'), + ), + migrations.AddField( + model_name='opportunity', + name='priority', + field=models.CharField(choices=[('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], default='medium', max_length=20, verbose_name='Priority'), + ), + migrations.AddField( + model_name='opportunity', + name='salary', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Salary'), + ), + migrations.AddField( + model_name='opportunity', + name='vrn', + field=models.CharField(blank=True, max_length=20, null=True, verbose_name='VRN'), + ), + ] diff --git a/inventory/migrations/0010_alter_opportunity_assigned_to.py b/inventory/migrations/0010_alter_opportunity_assigned_to.py new file mode 100644 index 00000000..c13f0d99 --- /dev/null +++ b/inventory/migrations/0010_alter_opportunity_assigned_to.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.7 on 2025-05-29 15:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0009_lead_address_opportunity_crn_opportunity_priority_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='opportunity', + name='assigned_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_opportunities', to='inventory.staff'), + ), + ] diff --git a/inventory/migrations/0011_remove_opportunity_assigned_to.py b/inventory/migrations/0011_remove_opportunity_assigned_to.py new file mode 100644 index 00000000..866e59c1 --- /dev/null +++ b/inventory/migrations/0011_remove_opportunity_assigned_to.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.7 on 2025-05-29 15:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0010_alter_opportunity_assigned_to'), + ] + + operations = [ + migrations.RemoveField( + model_name='opportunity', + name='assigned_to', + ), + ] diff --git a/inventory/migrations/0012_opportunity_organization.py b/inventory/migrations/0012_opportunity_organization.py new file mode 100644 index 00000000..98d415c9 --- /dev/null +++ b/inventory/migrations/0012_opportunity_organization.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.7 on 2025-05-29 16:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0011_remove_opportunity_assigned_to'), + ] + + operations = [ + migrations.AddField( + model_name='opportunity', + name='organization', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.organization', verbose_name='Organization'), + ), + ] diff --git a/inventory/migrations/0013_opportunity_amount_and_more.py b/inventory/migrations/0013_opportunity_amount_and_more.py new file mode 100644 index 00000000..c3f87230 --- /dev/null +++ b/inventory/migrations/0013_opportunity_amount_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2025-05-29 23:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0012_opportunity_organization'), + ] + + operations = [ + migrations.AddField( + model_name='opportunity', + name='amount', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Amount'), + ), + migrations.AlterField( + model_name='opportunity', + name='expected_revenue', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Expected Revenue'), + ), + ] diff --git a/inventory/migrations/0014_alter_opportunity_amount.py b/inventory/migrations/0014_alter_opportunity_amount.py new file mode 100644 index 00000000..cabd9e18 --- /dev/null +++ b/inventory/migrations/0014_alter_opportunity_amount.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.7 on 2025-05-29 23:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0013_opportunity_amount_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='opportunity', + name='amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Amount'), + preserve_default=False, + ), + ] diff --git a/inventory/models.py b/inventory/models.py index 34dee64d..633aef45 100644 --- a/inventory/models.py +++ b/inventory/models.py @@ -1,4 +1,6 @@ import uuid +from datetime import datetime +from django.conf import settings from django.contrib.auth.models import Permission from decimal import Decimal from django.utils.text import slugify @@ -21,9 +23,14 @@ from phonenumber_field.modelfields import PhoneNumberField from django.utils.timezone import now from django.contrib.auth.models import Group -from inventory.utils import get_user_type, to_dict +from inventory.utils import get_user_type, make_random_password, to_dict from .mixins import LocalizedNameMixin -from django_ledger.models import EstimateModel,InvoiceModel,AccountModel,EntityManagementModel +from django_ledger.models import ( + EstimateModel, + InvoiceModel, + AccountModel, + EntityManagementModel, +) from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from appointment.models import StaffMember @@ -32,19 +39,35 @@ from plans.models import UserPlan # from plans.models import AbstractPlan # from simple_history.models import HistoricalRecords + class Base(models.Model): - id = models.UUIDField(unique=True, editable=False, default=uuid.uuid4, primary_key=True,verbose_name=_("Primary Key")) - slug = models.SlugField(null=True, blank=True, unique=True,verbose_name=_("Slug"), - help_text=_("Slug for the object. If not provided, it will be generated automatically.")) + id = models.UUIDField( + unique=True, + editable=False, + default=uuid.uuid4, + primary_key=True, + verbose_name=_("Primary Key"), + ) + slug = models.SlugField( + null=True, + blank=True, + unique=True, + verbose_name=_("Slug"), + help_text=_( + "Slug for the object. If not provided, it will be generated automatically." + ), + ) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + def clean(self): if isinstance(self.id, str): try: uuid.UUID(self.id) except ValueError: - raise ValidationError({'id': 'Invalid UUID format'}) + raise ValidationError({"id": "Invalid UUID format"}) super().clean() + class Meta: abstract = True @@ -92,8 +115,13 @@ class DealersMake(models.Model): :ivar added_at: The date and time when the relationship was created. :type added_at: DateTimeField """ - dealer = models.ForeignKey("Dealer", on_delete=models.CASCADE, related_name="dealer_makes") - car_make = models.ForeignKey("CarMake", on_delete=models.CASCADE, related_name="car_dealers") + + dealer = models.ForeignKey( + "Dealer", on_delete=models.CASCADE, related_name="dealer_makes" + ) + car_make = models.ForeignKey( + "CarMake", on_delete=models.CASCADE, related_name="car_dealers" + ) added_at = models.DateTimeField(auto_now_add=True) class Meta: @@ -127,6 +155,7 @@ class StaffUserManager(UserManager): ) return user + class EmailStatus(models.TextChoices): SENT = "SENT", "Sent" FAILED = "FAILED", "Failed" @@ -134,6 +163,7 @@ class EmailStatus(models.TextChoices): OPEN = "OPEN", "Open" DRAFT = "DRAFT", "Draft" + class UnitOfMeasure(models.TextChoices): EACH = "EA", "Each" PAIR = "PR", "Pair" @@ -192,29 +222,40 @@ class CarMake(models.Model, LocalizedNameMixin): self.slug = base_slug counter = 1 - while self.__class__.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while ( + self.__class__.objects.filter(slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): self.slug = f"{base_slug}-{counter}" counter += 1 super().save(*args, **kwargs) + def __str__(self): return self.name class Meta: verbose_name = _("Make") + class CarModel(models.Model, LocalizedNameMixin): id_car_model = models.AutoField(primary_key=True) id_car_make = models.ForeignKey(CarMake, models.DO_NOTHING, db_column="id_car_make") name = models.CharField(max_length=255, blank=True, null=True) arabic_name = models.CharField(max_length=255, blank=True, null=True) slug = models.SlugField(max_length=255, unique=True, blank=True, null=True) + def save(self, *args, **kwargs): if not self.slug: base_slug = slugify(self.name) self.slug = base_slug counter = 1 - while self.__class__.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while ( + self.__class__.objects.filter(slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): self.slug = f"{base_slug}-{counter}" counter += 1 super().save(*args, **kwargs) @@ -237,13 +278,18 @@ class CarSerie(models.Model, LocalizedNameMixin): year_end = models.IntegerField(blank=True, null=True) generation_name = models.CharField(max_length=255, blank=True, null=True) slug = models.SlugField(max_length=255, unique=True, blank=True, null=True) + def save(self, *args, **kwargs): if not self.slug: base_slug = slugify(self.name) self.slug = base_slug counter = 1 - while self.__class__.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while ( + self.__class__.objects.filter(slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): self.slug = f"{base_slug}-{counter}" counter += 1 super().save(*args, **kwargs) @@ -265,13 +311,18 @@ class CarTrim(models.Model, LocalizedNameMixin): start_production_year = models.IntegerField(blank=True, null=True) end_production_year = models.IntegerField(blank=True, null=True) slug = models.SlugField(max_length=255, unique=True, blank=True, null=True) + def save(self, *args, **kwargs): if not self.slug: base_slug = slugify(self.name) self.slug = base_slug counter = 1 - while self.__class__.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while ( + self.__class__.objects.filter(slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): self.slug = f"{base_slug}-{counter}" counter += 1 super().save(*args, **kwargs) @@ -290,6 +341,7 @@ class CarEquipment(models.Model, LocalizedNameMixin): arabic_name = models.CharField(max_length=255, blank=True, null=True) year_begin = models.IntegerField(blank=True, null=True) slug = models.SlugField(max_length=255, unique=True, blank=True, null=True) + def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) @@ -310,13 +362,18 @@ class CarSpecification(models.Model, LocalizedNameMixin): "self", models.DO_NOTHING, db_column="id_parent", blank=True, null=True ) slug = models.SlugField(max_length=255, unique=True, blank=True, null=True) + def save(self, *args, **kwargs): if not self.slug: base_slug = slugify(self.name) self.slug = base_slug counter = 1 - while self.__class__.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while ( + self.__class__.objects.filter(slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): self.slug = f"{base_slug}-{counter}" counter += 1 super().save(*args, **kwargs) @@ -352,13 +409,18 @@ class CarOption(models.Model, LocalizedNameMixin): "self", models.DO_NOTHING, db_column="id_parent", blank=True, null=True ) slug = models.SlugField(max_length=255, unique=True, blank=True, null=True) + def save(self, *args, **kwargs): if not self.slug: base_slug = slugify(self.name) self.slug = base_slug counter = 1 - while self.__class__.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while ( + self.__class__.objects.filter(slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): self.slug = f"{base_slug}-{counter}" counter += 1 super().save(*args, **kwargs) @@ -407,6 +469,7 @@ class CarStatusChoices(models.TextChoices): RESERVED = "reserved", _("Reserved") TRANSFER = "transfer", _("Transfer") + class CarStockTypeChoices(models.TextChoices): NEW = "new", _("New") USED = "used", _("Used") @@ -436,7 +499,6 @@ class AdditionalServices(models.Model, LocalizedNameMixin): blank=True, ) - def to_dict(self): return { "name": self.name, @@ -445,10 +507,16 @@ class AdditionalServices(models.Model, LocalizedNameMixin): "taxable": self.taxable, "uom": self.uom, } + @property def price_(self): vat = VatRate.objects.filter(is_active=True).first() - return Decimal(self.price + (self.price * vat.rate)) if self.taxable else self.price + return ( + Decimal(self.price + (self.price * vat.rate)) + if self.taxable + else self.price + ) + class Meta: verbose_name = _("Additional Services") verbose_name_plural = _("Additional Services") @@ -524,7 +592,9 @@ class Car(Base): remarks = models.TextField(blank=True, null=True, verbose_name=_("Remarks")) mileage = models.IntegerField(blank=True, null=True, verbose_name=_("Mileage")) receiving_date = models.DateTimeField(verbose_name=_("Receiving Date")) - hash = models.CharField(max_length=64, blank=True, null=True, verbose_name=_("Hash")) + hash = models.CharField( + max_length=64, blank=True, null=True, verbose_name=_("Hash") + ) # history = HistoricalRecords() def save(self, *args, **kwargs): @@ -545,8 +615,10 @@ class Car(Base): @property def product(self): return self.dealer.entity.get_items_all().filter(name=self.vin).first() + def get_reservation(self): return self.reservations.filter(reserved_until__gt=now()).first() + def is_reserved(self): active_reservations = self.reservations.filter(reserved_until__gt=now()) return active_reservations.exists() @@ -554,11 +626,18 @@ class Car(Base): @property def ready(self): try: - return all([self.colors ,self.finances,]) + return all( + [ + self.colors, + self.finances, + ] + ) except Exception: return False + def get_transfer(self): return self.transfer_logs.filter(active=True).first() + @property def get_car_group(self): return f"{self.id_car_make.get_local_name} {self.id_car_model.get_local_name}" @@ -576,7 +655,7 @@ class Car(Base): year = self.year if self.year else 0 serie = self.id_car_serie.name if self.id_car_serie else "" trim = self.id_car_trim.name if self.id_car_trim else "" - hash_object.update(f"{make}{model}{year}{serie}{trim}{color}".encode('utf-8')) + hash_object.update(f"{make}{model}{year}{serie}{trim}{color}".encode("utf-8")) return hash_object.hexdigest() def mark_as_sold(self): @@ -587,6 +666,7 @@ class Car(Base): def cancel_reservation(self): if self.reservations.exists(): self.reservations.all().delete() + def cancel_transfer(self): if self.transfer_logs.exists(): self.transfer_logs.all().delete() @@ -603,8 +683,8 @@ class Car(Base): "stock_type": self.stock_type, "remarks": self.remarks, "mileage": self.mileage, - "receiving_date": self.receiving_date.strftime('%Y-%m-%d %H:%M:%S'), - 'hash': self.get_hash, + "receiving_date": self.receiving_date.strftime("%Y-%m-%d %H:%M:%S"), + "hash": self.get_hash, "id": str(self.id), } @@ -613,11 +693,26 @@ class Car(Base): return specs def get_inventory_account(self): - return self.dealer.entity.get_all_accounts().filter(name=f"Inventory:{self.id_car_make.name}").first() + return ( + self.dealer.entity.get_all_accounts() + .filter(name=f"Inventory:{self.id_car_make.name}") + .first() + ) + def get_revenue_account(self): - return self.dealer.entity.get_all_accounts().filter(name=f"Revenue:{self.id_car_make.name}").first() + return ( + self.dealer.entity.get_all_accounts() + .filter(name=f"Revenue:{self.id_car_make.name}") + .first() + ) + def get_cogs_account(self): - return self.dealer.entity.get_all_accounts().filter(name=f"Cogs:{self.id_car_make.name}").first() + return ( + self.dealer.entity.get_all_accounts() + .filter(name=f"Cogs:{self.id_car_make.name}") + .first() + ) + class CarTransfer(models.Model): car = models.ForeignKey( @@ -641,7 +736,7 @@ class CarTransfer(models.Model): transfer_date = models.DateTimeField( auto_now_add=True, verbose_name=_("Transfer Date") ) - quantity = models.IntegerField(verbose_name=_("Quantity"),default=1) + quantity = models.IntegerField(verbose_name=_("Quantity"), default=1) remarks = models.TextField(blank=True, null=True, verbose_name=_("Remarks")) status = models.CharField( CarTransferStatusChoices.choices, @@ -656,6 +751,7 @@ class CarTransfer(models.Model): @property def total_price(self): return self.quantity * self.car.finances.total_vat + class Meta: verbose_name = _("Car Transfer Log") verbose_name_plural = _("Car Transfer Logs") @@ -686,7 +782,6 @@ class CarReservation(models.Model): reserved_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Reserved At")) reserved_until = models.DateTimeField(verbose_name=_("Reserved Until")) - @property def is_active(self): return self.reserved_until > now() @@ -718,7 +813,6 @@ class CarFinance(models.Model): ) is_sold = models.BooleanField(default=False) - @property def total(self): return self.selling_price @@ -730,6 +824,7 @@ class CarFinance(models.Model): @property def total_additionals(self): return sum(x.price_ for x in self.additional_services.all()) + @property def total_discount(self): if self.discount_amount > 0: @@ -738,7 +833,7 @@ class CarFinance(models.Model): @property def total_vat(self): - return round(self.total_discount + self.vat_amount + self.total_additionals,2) + return round(self.total_discount + self.vat_amount + self.total_additionals, 2) @property def vat_amount(self): @@ -761,6 +856,7 @@ class CarFinance(models.Model): "total_vat": str(self.total_vat), "vat_amount": str(self.vat_amount), } + def __str__(self): return f"Car: {self.car}, Selling Price: {self.selling_price}" @@ -891,8 +987,12 @@ class CarRegistration(models.Model): ) plate_number = models.IntegerField(verbose_name=_("Plate Number")) text1 = models.CharField(max_length=1, verbose_name=_("Text 1")) - text2 = models.CharField(max_length=1, verbose_name=_("Text 2"), null=True, blank=True) - text3 = models.CharField(max_length=1, verbose_name=_("Text 3"), null=True, blank=True) + text2 = models.CharField( + max_length=1, verbose_name=_("Text 2"), null=True, blank=True + ) + text3 = models.CharField( + max_length=1, verbose_name=_("Text 3"), null=True, blank=True + ) registration_date = models.DateTimeField(verbose_name=_("Registration Date")) class Meta: @@ -949,7 +1049,7 @@ class Dealer(models.Model, LocalizedNameMixin): @property def active_plan(self): try: - plan = UserPlan.objects.get(user=self.user,active=True).plan + plan = UserPlan.objects.get(user=self.user, active=True).plan return plan except Exception as e: @@ -977,7 +1077,8 @@ class Dealer(models.Model, LocalizedNameMixin): return None def get_vendors(self): - return VendorModel.objects.filter(entity_model=self.entity) + return VendorModel.objects.filter(entity_model=self.entity) + @property def is_staff_exceed_quota_limit(self): quota = self.user_quota @@ -1008,16 +1109,22 @@ class StaffTypes(models.TextChoices): class Staff(models.Model, LocalizedNameMixin): - staff_member = models.OneToOneField(StaffMember, on_delete=models.CASCADE, related_name="staff") + staff_member = models.OneToOneField( + StaffMember, on_delete=models.CASCADE, related_name="staff" + ) dealer = models.ForeignKey(Dealer, on_delete=models.CASCADE, related_name="staff") name = models.CharField(max_length=255, verbose_name=_("Name")) arabic_name = models.CharField(max_length=255, verbose_name=_("Arabic Name")) phone_number = PhoneNumberField(region="SA", verbose_name=_("Phone Number")) - staff_type = models.CharField(choices=StaffTypes.choices, max_length=255, verbose_name=_("Staff Type")) + staff_type = models.CharField( + choices=StaffTypes.choices, max_length=255, verbose_name=_("Staff Type") + ) active = models.BooleanField(default=True, verbose_name=_("Active")) created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created")) updated = models.DateTimeField(auto_now=True, verbose_name=_("Updated")) - slug = models.SlugField(max_length=255, unique=True, editable=False, null=True, blank=True) + slug = models.SlugField( + max_length=255, unique=True, editable=False, null=True, blank=True + ) def save(self, *args, **kwargs): if not self.slug: @@ -1025,29 +1132,38 @@ class Staff(models.Model, LocalizedNameMixin): self.slug = base_slug counter = 1 - while self.__class__.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while ( + self.__class__.objects.filter(slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): self.slug = f"{base_slug}-{counter}" counter += 1 super().save(*args, **kwargs) objects = StaffUserManager() + def deactivate_account(self): self.active = False self.user.is_active = False self.user.save() self.save() + def activate_account(self): self.active = True self.user.is_active = True self.user.save() self.save() + def permenant_delete(self): # self.user.delete() self.staff_member.delete() self.delete() + @property def email(self): return self.staff_member.user.email + @property def user(self): return self.staff_member.user @@ -1056,28 +1172,33 @@ class Staff(models.Model, LocalizedNameMixin): def groups(self): return [x.customgroup for x in self.user.groups.all()] - def clear_groups(self): - EntityManagementModel.objects.filter(user=self.user,entity=self.dealer.entity).delete() + EntityManagementModel.objects.filter( + user=self.user, entity=self.dealer.entity + ).delete() return self.user.groups.clear() - def add_group(self,group): + def add_group(self, group): try: self.user.groups.add(group) - if self.staff_type in ["accountant","manager"]: + if self.staff_type in ["accountant", "manager"]: self.add_as_manager() except Exception as e: print(e) + def add_as_manager(self): - if self.staff_type in ["accountant","manager"]: + if self.staff_type in ["accountant", "manager"]: EntityManagementModel.objects.get_or_create( - user=self.user,entity=self.dealer.entity + user=self.user, entity=self.dealer.entity ) else: self.user.groups.clear() - group = Group.objects.filter(customgroup__name__iexact=self.staff_type).first() + group = Group.objects.filter( + customgroup__name__iexact=self.staff_type + ).first() if group: - self.add_group(group) + self.add_group(group) + class Meta: verbose_name = _("Staff") verbose_name_plural = _("Staff") @@ -1111,11 +1232,10 @@ class Channel(models.TextChoices): class Status(models.TextChoices): NEW = "new", _("New") - FOLLOW_UP = "follow_up", _("Follow-up") - NEGOTIATION = "negotiation", _("Negotiation") - WON = "won", _("Won") - LOST = "lost", _("Lost") - CLOSED = "closed", _("Closed") + CONTACTED = "contacted", _("Contacted") + QUALIFIED = "qualified", _("Qualified") + UNQUALIFIED = "unqualified", _("Unqualified") + CONVERTED = "converted", _("Converted") class Title(models.TextChoices): @@ -1159,11 +1279,14 @@ class ActionChoices(models.TextChoices): class Stage(models.TextChoices): - DISCOVERY = "discovery", _("Discovery") - PROPOSAL = "proposal", _("Proposal") + QUALIFICATION = "qualification", _("Qualification") + TEST_DRIVE = "test_drive", _("Test Drive") + QUOTATION = "quotation", _("Quotation") NEGOTIATION = "negotiation", _("Negotiation") + FINANCING = "financing", _("Financing") CLOSED_WON = "closed_won", _("Closed Won") CLOSED_LOST = "closed_lost", _("Closed Lost") + ON_HOLD = "on_hold", _("On Hold") class Priority(models.TextChoices): @@ -1172,15 +1295,16 @@ class Priority(models.TextChoices): HIGH = "high", _("High") - class Customer(models.Model): dealer = models.ForeignKey( Dealer, on_delete=models.CASCADE, related_name="customers" ) customer_model = models.ForeignKey( - CustomerModel, on_delete=models.SET_NULL, null=True + CustomerModel, on_delete=models.SET_NULL, null=True + ) + user = models.OneToOneField( + User, on_delete=models.CASCADE, related_name="customer_profile" ) - user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='customer_profile') title = models.CharField( choices=Title.choices, default=Title.NA, max_length=10, verbose_name=_("Title") ) @@ -1197,7 +1321,7 @@ class Customer(models.Model): dob = models.DateField(verbose_name=_("Date of Birth"), null=True, blank=True) email = models.EmailField(unique=True, verbose_name=_("Email")) national_id = models.CharField( - max_length=10, unique=True, verbose_name=_("National ID"), null=True,blank=True + max_length=10, unique=True, verbose_name=_("National ID"), null=True, blank=True ) phone_number = PhoneNumberField( region="SA", unique=True, verbose_name=_("Phone Number") @@ -1211,7 +1335,9 @@ class Customer(models.Model): ) created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created")) updated = models.DateTimeField(auto_now=True, verbose_name=_("Updated")) - slug = models.SlugField(max_length=255, unique=True, editable=False, null=True, blank=True) + slug = models.SlugField( + max_length=255, unique=True, editable=False, null=True, blank=True + ) def save(self, *args, **kwargs): if not self.slug: @@ -1219,7 +1345,11 @@ class Customer(models.Model): self.slug = base_slug counter = 1 - while self.__class__.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while ( + self.__class__.objects.filter(slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): self.slug = f"{base_slug}-{counter}" counter += 1 super().save(*args, **kwargs) @@ -1236,7 +1366,7 @@ class Customer(models.Model): def full_name(self): return f"{self.first_name} {self.last_name}" - def create_customer_model(self): + def create_customer_model(self,for_lead=False): customer_dict = to_dict(self) customer = self.dealer.entity.create_customer( commit=False, @@ -1251,6 +1381,7 @@ class Customer(models.Model): customer.additional_info.update({"customer_info": customer_dict}) except Exception: pass + customer.active = False if for_lead else True customer.save() self.customer_model = customer self.save() @@ -1263,6 +1394,7 @@ class Customer(models.Model): user.email = self.email user.save() return user + def update_customer_model(self): customer_dict = to_dict(self) customer = self.customer_model @@ -1277,18 +1409,21 @@ class Customer(models.Model): customer.save() return customer - def create_user_model(self): + def create_user_model(self,for_lead=False): user = User.objects.create_user( username=self.email, email=self.email, first_name=self.first_name, last_name=self.last_name, + password=make_random_password(), + is_staff=False, + is_superuser=False, + is_active=False if for_lead else True, ) - user.set_password("Tenhal@123") - user.save() self.user = user self.save() return user + def deactivate_account(self): self.active = False self.customer_model.active = False @@ -1296,6 +1431,7 @@ class Customer(models.Model): self.customer_model.save() self.user.save() self.save() + def activate_account(self): self.active = True self.customer_model.active = True @@ -1303,18 +1439,23 @@ class Customer(models.Model): self.customer_model.save() self.user.save() self.save() + def permenant_delete(self): self.customer_model.delete() self.user.delete() self.delete() + + class Organization(models.Model, LocalizedNameMixin): dealer = models.ForeignKey( Dealer, on_delete=models.CASCADE, related_name="organizations" ) customer_model = models.ForeignKey( - CustomerModel, on_delete=models.SET_NULL, null=True + CustomerModel, on_delete=models.SET_NULL, null=True + ) + user = models.OneToOneField( + User, on_delete=models.CASCADE, related_name="organization_profile" ) - user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='organization_profile') name = models.CharField(max_length=255, verbose_name=_("Name")) arabic_name = models.CharField(max_length=255, verbose_name=_("Arabic Name")) crn = models.CharField( @@ -1332,7 +1473,9 @@ class Organization(models.Model, LocalizedNameMixin): active = models.BooleanField(default=True, verbose_name=_("Active")) created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created")) updated = models.DateTimeField(auto_now=True, verbose_name=_("Updated")) - slug = models.SlugField(max_length=255, unique=True, editable=False, null=True, blank=True) + slug = models.SlugField( + max_length=255, unique=True, editable=False, null=True, blank=True + ) def save(self, *args, **kwargs): if not self.slug: @@ -1340,7 +1483,11 @@ class Organization(models.Model, LocalizedNameMixin): self.slug = base_slug counter = 1 - while self.__class__.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while ( + self.__class__.objects.filter(slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): self.slug = f"{base_slug}-{counter}" counter += 1 super().save(*args, **kwargs) @@ -1351,7 +1498,8 @@ class Organization(models.Model, LocalizedNameMixin): def __str__(self): return self.name - def create_customer_model(self): + + def create_customer_model(self,for_lead=False): customer_dict = to_dict(self) customer = self.dealer.entity.create_customer( commit=False, @@ -1366,6 +1514,7 @@ class Organization(models.Model, LocalizedNameMixin): customer.additional_info.update({"customer_info": customer_dict}) except Exception: pass + customer.active = False if for_lead else True customer.save() self.customer_model = customer self.save() @@ -1377,6 +1526,7 @@ class Organization(models.Model, LocalizedNameMixin): user.email = self.email user.save() return user + def update_customer_model(self): customer_dict = to_dict(self) customer = self.customer_model @@ -1391,14 +1541,16 @@ class Organization(models.Model, LocalizedNameMixin): customer.save() return customer - def create_user_model(self): + def create_user_model(self,for_lead=False): user = User.objects.create_user( username=self.email, email=self.email, first_name=self.name, + password=make_random_password(), + is_staff=False, + is_superuser=False, + is_active=False if for_lead else True, ) - user.set_password("Tenhal@123") - user.save() self.user = user self.save() return user @@ -1410,6 +1562,7 @@ class Organization(models.Model, LocalizedNameMixin): self.user.save() self.customer_model.save() self.save() + def activate_account(self): self.active = True self.customer_model.active = True @@ -1417,11 +1570,13 @@ class Organization(models.Model, LocalizedNameMixin): self.customer_model.save() self.user.save() self.save() + def permenant_delete(self): self.user.delete() self.customer_model.delete() self.delete() + class Representative(models.Model, LocalizedNameMixin): dealer = models.ForeignKey( Dealer, on_delete=models.CASCADE, related_name="representatives" @@ -1452,16 +1607,28 @@ class Lead(models.Model): last_name = models.CharField(max_length=50, verbose_name=_("Last Name")) email = models.EmailField(verbose_name=_("Email")) phone_number = PhoneNumberField(region="SA", verbose_name=_("Phone Number")) + address = models.CharField( + max_length=200, blank=True, null=True, verbose_name=_("Address") + ) lead_type = models.CharField( - max_length=50, choices=[("customer", _("Customer")), ("organization", _("Organization"))], verbose_name=_("Lead Type") - ,default="customer") + max_length=50, + choices=[("customer", _("Customer")), ("organization", _("Organization"))], + verbose_name=_("Lead Type"), + default="customer", + ) customer = models.ForeignKey( - Customer, on_delete=models.CASCADE, related_name="customer_leads", - null=True,blank=True + Customer, + on_delete=models.CASCADE, + related_name="customer_leads", + null=True, + blank=True, ) organization = models.ForeignKey( - Organization, on_delete=models.CASCADE, related_name="organization_leads", - null=True,blank=True + Organization, + on_delete=models.CASCADE, + related_name="organization_leads", + null=True, + blank=True, ) id_car_make = models.ForeignKey( CarMake, @@ -1477,22 +1644,13 @@ class Lead(models.Model): null=True, verbose_name=_("Model"), ) - year = models.PositiveSmallIntegerField( - verbose_name=_("Year"), blank=True, null=True - ) + source = models.CharField( max_length=50, choices=Sources.choices, verbose_name=_("Source") ) channel = models.CharField( max_length=50, choices=Channel.choices, verbose_name=_("Channel") ) - crn = models.CharField( - max_length=10, unique=True, verbose_name=_("Commercial Registration Number"), blank=True, null=True - ) - vrn = models.CharField( - max_length=15, unique=True, verbose_name=_("VAT Registration Number"), blank=True, null=True - ) - address = models.CharField(max_length=50, verbose_name=_("address")) staff = models.ForeignKey( Staff, on_delete=models.SET_NULL, @@ -1501,12 +1659,6 @@ class Lead(models.Model): related_name="assigned", verbose_name=_("Assigned"), ) - priority = models.CharField( - max_length=10, - choices=Priority.choices, - default=Priority.MEDIUM, - verbose_name=_("Priority"), - ) status = models.CharField( max_length=50, choices=Status.choices, @@ -1515,19 +1667,14 @@ class Lead(models.Model): default=Status.NEW, ) next_action = models.CharField( - max_length=255, - verbose_name=_("Next Action"), - blank=True, - null=True + max_length=255, verbose_name=_("Next Action"), blank=True, null=True ) next_action_date = models.DateTimeField( - verbose_name=_("Next Action Date"), - blank=True, - null=True + verbose_name=_("Next Action Date"), blank=True, null=True ) is_converted = models.BooleanField(default=False) converted_at = models.DateTimeField(null=True, blank=True) - salary = models.PositiveIntegerField(verbose_name=_("Salary"), blank=True, null=True) + created = models.DateTimeField( auto_now_add=True, verbose_name=_("Created"), db_index=True ) @@ -1543,6 +1690,7 @@ class Lead(models.Model): def get_user_model(self): return User.objects.get(email=self.email) or None + @property def activities(self): return Activity.objects.filter(dealer=self.dealer, object_id=self.id) @@ -1552,45 +1700,39 @@ class Lead(models.Model): "first_name": str(self.first_name), "last_name": str(self.last_name), "email": str(self.email), - "address": str(self.address), "phone_number": str(self.phone_number), "make": str(self.id_car_make.name), "model": str(self.id_car_model.name), "created_at": str(self.created.strftime("%Y-%m-%d")), } + @property def full_name(self): return f"{self.first_name} {self.last_name}" + def convert_to_customer(self): - self.status = Status.NEGOTIATION + self.status = Status.CONVERTED self.is_converted = True + self.converted_at = datetime.now() + if self.customer: + self.customer.activate_account() + self.customer.save() + if self.organization: + self.organization.activate_account() + self.organization.save() self.save() return self.get_customer_model() - def get_status(self): - if self.is_converted: - return Status.WON - - latest_activity = self.activities.order_by('-updated').first() - if latest_activity: - time_since_last = timezone.now() - latest_activity.updated - if "negotiation" in latest_activity.activity_type.lower(): - return Status.NEGOTIATION - elif time_since_last > timedelta(days=3): - return Status.FOLLOW_UP - else: - return Status.NEW - - return self.status @property def needs_follow_up(self): - latest = self.activities.order_by('-updated').first() + latest = self.activities.order_by("-updated").first() if not latest: return True return (timezone.now() - latest.updated).days > 3 + @property def stale_leads(self): - latest = self.activities.order_by('-updated').first() + latest = self.activities.order_by("-updated").first() if not latest: return True return (timezone.now() - latest.updated).days > 7 @@ -1600,25 +1742,48 @@ class Lead(models.Model): return self.customer.customer_model if self.organization: return self.organization.customer_model + def get_latest_schedule(self): - return self.schedules.order_by('-scheduled_at').first() + return self.schedules.order_by("-scheduled_at").first() + def get_latest_schedules(self): - return self.schedules.filter(scheduled_at__gt=now()).exclude(status='Canceled').order_by('-scheduled_at')[:5] + return ( + self.schedules.filter(scheduled_at__gt=now()) + .exclude(status="Canceled") + .order_by("-scheduled_at")[:5] + ) + def get_all_schedules(self): - return self.schedules.all().order_by('-scheduled_at') + return self.schedules.all().order_by("-scheduled_at") + def get_calls(self): - return self.get_all_schedules().filter(scheduled_type='call') + return self.get_all_schedules().filter(scheduled_type="call") + def get_meetings(self): - return self.get_all_schedules().filter(scheduled_type='meeting') + return self.get_all_schedules().filter(scheduled_type="meeting") + def get_emails(self): return Email.objects.filter(content_type__model="lead", object_id=self.pk) + def get_notes(self): return Notes.objects.filter(content_type__model="lead", object_id=self.pk) + def get_activities(self): - return Activity.objects.filter(dealer=self.dealer,content_type__model="lead", object_id=self.pk).order_by('-updated') + return Activity.objects.filter( + dealer=self.dealer, content_type__model="lead", object_id=self.pk + ).order_by("-updated") + + def get_opportunities(self): + return Opportunity.objects.filter(lead=self) @property def get_current_action(self): - return Activity.objects.filter(dealer=self.dealer,content_type__model="lead", object_id=self.pk).order_by('-updated').first() + return ( + Activity.objects.filter( + dealer=self.dealer, content_type__model="lead", object_id=self.pk + ) + .order_by("-updated") + .first() + ) def save(self, *args, **kwargs): if not self.slug: @@ -1626,39 +1791,54 @@ class Lead(models.Model): self.slug = base_slug counter = 1 - while self.__class__.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while ( + self.__class__.objects.filter(slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): self.slug = f"{base_slug}-{counter}" counter += 1 super().save(*args, **kwargs) + class Schedule(models.Model): PURPOSE_CHOICES = [ - ('product_demo', _('Product Demo')), - ('follow_up_call', _('Follow-Up Call')), - ('contract_discussion', _('Contract Discussion')), - ('sales_meeting', _('Sales Meeting')), - ('support_call', _('Support Call')), - ('other', _('Other')), + ("product_demo", _("Product Demo")), + ("follow_up_call", _("Follow-Up Call")), + ("contract_discussion", _("Contract Discussion")), + ("sales_meeting", _("Sales Meeting")), + ("support_call", _("Support Call")), + ("other", _("Other")), ] ScheduledType = [ - ('call', _('Call')), - ('meeting', _('Meeting')), - ('email', _('Email')), + ("call", _("Call")), + ("meeting", _("Meeting")), + ("email", _("Email")), ] ScheduleStatusChoices = [ - ('scheduled', _('Scheduled')), - ('completed', _('Completed')), - ('canceled', _('Canceled')), + ("scheduled", _("Scheduled")), + ("completed", _("Completed")), + ("canceled", _("Canceled")), ] - lead = models.ForeignKey(Lead, on_delete=models.CASCADE, related_name='schedules') - customer = models.ForeignKey(CustomerModel, on_delete=models.CASCADE, related_name='schedules',null=True,blank=True) + lead = models.ForeignKey(Lead, on_delete=models.CASCADE, related_name="schedules") + customer = models.ForeignKey( + CustomerModel, + on_delete=models.CASCADE, + related_name="schedules", + null=True, + blank=True, + ) scheduled_by = models.ForeignKey(User, on_delete=models.CASCADE) purpose = models.CharField(max_length=200, choices=PURPOSE_CHOICES) scheduled_at = models.DateTimeField() - scheduled_type = models.CharField(max_length=200, choices=ScheduledType,default='Call') + scheduled_type = models.CharField( + max_length=200, choices=ScheduledType, default="Call" + ) duration = models.DurationField(default=timedelta(minutes=5)) notes = models.TextField(blank=True, null=True) - status = models.CharField(max_length=200, choices=ScheduleStatusChoices, default='Scheduled') + status = models.CharField( + max_length=200, choices=ScheduleStatusChoices, default="Scheduled" + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -1672,7 +1852,7 @@ class Schedule(models.Model): return False class Meta: - ordering = ['-scheduled_at'] + ordering = ["-scheduled_at"] class LeadStatusHistory(models.Model): @@ -1708,11 +1888,30 @@ class Opportunity(models.Model): Dealer, on_delete=models.CASCADE, related_name="opportunities" ) customer = models.ForeignKey( - Customer, on_delete=models.CASCADE, related_name="opportunities",null=True,blank=True + Customer, + on_delete=models.CASCADE, + related_name="opportunities", + null=True, + blank=True, + ) + organization = models.ForeignKey( + Organization, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_("Organization"), ) car = models.ForeignKey( Car, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Car") ) + crn = models.CharField(max_length=20, verbose_name=_("CRN"), blank=True, null=True) + vrn = models.CharField(max_length=20, verbose_name=_("VRN"), blank=True, null=True) + salary = models.DecimalField( + max_digits=10, decimal_places=2, verbose_name=_("Salary"), blank=True, null=True + ) + priority = models.CharField( + max_length=20, choices=[("high", "High"), ("medium", "Medium"), ("low", "Low")], verbose_name=_("Priority"),default="medium" + ) stage = models.CharField( max_length=20, choices=Stage.choices, verbose_name=_("Stage") ) @@ -1723,27 +1922,67 @@ class Opportunity(models.Model): related_name="owner", verbose_name=_("Owner"), ) - lead = models.OneToOneField("Lead",related_name="opportunity", on_delete=models.CASCADE,null=True,blank=True) - probability = models.PositiveIntegerField(validators=[validate_probability]) - expected_revenue = models.DecimalField( - max_digits=10, decimal_places=2, verbose_name=_("Expected Revenue") + lead = models.OneToOneField( + "Lead", + related_name="opportunity", + on_delete=models.CASCADE, + null=True, + blank=True, ) - closing_date = models.DateField(verbose_name=_("Closing Date"),null=True,blank=True) + probability = models.PositiveIntegerField(validators=[validate_probability]) + amount = models.DecimalField( + max_digits=10, decimal_places=2, verbose_name=_("Amount"), + ) + expected_revenue = models.DecimalField( + max_digits=10, decimal_places=2, verbose_name=_("Expected Revenue"), blank=True, null=True + ) + vehicle_of_interest_make = models.CharField(max_length=50, blank=True, null=True) + vehicle_of_interest_model = models.CharField(max_length=100, blank=True, null=True) + expected_close_date = models.DateField(blank=True, null=True) created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created")) updated = models.DateTimeField(auto_now=True, verbose_name=_("Updated")) - estimate = models.OneToOneField(EstimateModel, related_name="opportunity",on_delete=models.SET_NULL,null=True,blank=True) - slug = models.SlugField(null=True, blank=True, unique=True,verbose_name=_("Slug"), - help_text=_("Unique slug for the opportunity.")) + estimate = models.OneToOneField( + EstimateModel, + related_name="opportunity", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + slug = models.SlugField( + null=True, + blank=True, + unique=True, + verbose_name=_("Slug"), + help_text=_("Unique slug for the opportunity."), + ) + loss_reason = models.CharField(max_length=255, blank=True, null=True) + + def save(self, *args, **kwargs): + if self.amount: + self.expected_revenue = self.amount * self.probability / 100 + opportinity_for = "" + if self.lead.lead_type == "customer": + self.customer = self.lead.customer + opportinity_for = self.customer.first_name + " " + self.customer.last_name + elif self.lead.lead_type == "organization": + self.organization = self.lead.organization + opportinity_for = self.organization.name + if not self.slug: - self.slug = slugify(f"opportunity {self.customer.first_name} {self.customer.last_name}") + self.slug = slugify( + f"opportunity {opportinity_for}" + ) super(Opportunity, self).save(*args, **kwargs) + class Meta: verbose_name = _("Opportunity") verbose_name_plural = _("Opportunities") def __str__(self): - return f"Opportunity for {self.customer.first_name} {self.customer.last_name}" + if self.customer: + return f"Opportunity for {self.customer.first_name} {self.customer.last_name}" + return f"Opportunity for {self.organization.name}" class Notes(models.Model): @@ -1765,17 +2004,22 @@ class Notes(models.Model): def __str__(self): return f"Note by {self.created_by.first_name} on {self.content_object}" + class Tasks(models.Model): dealer = models.ForeignKey(Dealer, on_delete=models.CASCADE, related_name="tasks") content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.UUIDField() content_object = GenericForeignKey("content_type", "object_id") title = models.CharField(max_length=255, verbose_name=_("Title")) - description = models.TextField(verbose_name=_("Description"),null=True,blank=True) + description = models.TextField(verbose_name=_("Description"), null=True, blank=True) due_date = models.DateField(verbose_name=_("Due Date")) completed = models.BooleanField(default=False, verbose_name=_("Completed")) assigned_to = models.ForeignKey( - User, on_delete=models.DO_NOTHING, related_name="tasks_assigned",null=True,blank=True + User, + on_delete=models.DO_NOTHING, + related_name="tasks_assigned", + null=True, + blank=True, ) created_by = models.ForeignKey( User, on_delete=models.DO_NOTHING, related_name="tasks_created" @@ -1790,15 +2034,21 @@ class Tasks(models.Model): def __str__(self): return f"Task by {self.created_by.email} on {self.content_object}" + class Email(models.Model): content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.UUIDField() content_object = GenericForeignKey("content_type", "object_id") - from_email = models.TextField(verbose_name=_("From Email"),null=True,blank=True) - to_email = models.TextField(verbose_name=_("To Email"),null=True,blank=True) - subject = models.TextField(verbose_name=_("Subject"),null=True,blank=True) - message = models.TextField(verbose_name=_("Message"),null=True,blank=True) - status = models.CharField(max_length=20, choices=EmailStatus.choices, verbose_name=_("Status"),default=EmailStatus.OPEN) + from_email = models.TextField(verbose_name=_("From Email"), null=True, blank=True) + to_email = models.TextField(verbose_name=_("To Email"), null=True, blank=True) + subject = models.TextField(verbose_name=_("Subject"), null=True, blank=True) + message = models.TextField(verbose_name=_("Message"), null=True, blank=True) + status = models.CharField( + max_length=20, + choices=EmailStatus.choices, + verbose_name=_("Status"), + default=EmailStatus.OPEN, + ) created_by = models.ForeignKey( User, on_delete=models.DO_NOTHING, related_name="emails_created" ) @@ -1814,7 +2064,9 @@ class Email(models.Model): class Activity(models.Model): - dealer = models.ForeignKey(Dealer, on_delete=models.CASCADE, related_name="activities") + dealer = models.ForeignKey( + Dealer, on_delete=models.CASCADE, related_name="activities" + ) content_type = models.ForeignKey(ContentType, on_delete=models.DO_NOTHING) object_id = models.PositiveIntegerField() content_object = GenericForeignKey("content_type", "object_id") @@ -1854,7 +2106,7 @@ class Notification(models.Model): @classmethod def has_new_notifications(cls, user): - return cls.objects.filter(user=user,is_read=False).exists() + return cls.objects.filter(user=user, is_read=False).exists() @classmethod def get_notification_data(cls, user): @@ -1870,22 +2122,26 @@ class Vendor(models.Model, LocalizedNameMixin): max_length=15, unique=True, verbose_name=_("VAT Registration Number") ) vendor_model = models.ForeignKey( - VendorModel, on_delete=models.DO_NOTHING, verbose_name=_("Vendor Model"),null=True,blank=True + VendorModel, + on_delete=models.DO_NOTHING, + verbose_name=_("Vendor Model"), + null=True, + blank=True, ) arabic_name = models.CharField(max_length=255, verbose_name=_("Arabic Name")) name = models.CharField(max_length=255, verbose_name=_("English Name")) contact_person = models.CharField(max_length=100, verbose_name=_("Contact Person")) phone_number = PhoneNumberField(region="SA", verbose_name=_("Phone Number")) email = models.EmailField(max_length=255, verbose_name=_("Email Address")) - address = models.CharField( - max_length=200, verbose_name=_("Address") - ) + address = models.CharField(max_length=200, verbose_name=_("Address")) logo = models.ImageField( upload_to="logos/vendors", blank=True, null=True, verbose_name=_("Logo") ) active = models.BooleanField(default=True, verbose_name=_("Active")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) - slug = models.SlugField(max_length=255, unique=True, verbose_name=_("Slug"), null=True,blank=True) + slug = models.SlugField( + max_length=255, unique=True, verbose_name=_("Slug"), null=True, blank=True + ) def save(self, *args, **kwargs): if not self.slug: @@ -1893,10 +2149,15 @@ class Vendor(models.Model, LocalizedNameMixin): self.slug = base_slug counter = 1 - while self.__class__.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + while ( + self.__class__.objects.filter(slug=self.slug) + .exclude(pk=self.pk) + .exists() + ): self.slug = f"{base_slug}-{counter}" counter += 1 super().save(*args, **kwargs) + class Meta: verbose_name = _("Vendor") verbose_name_plural = _("Vendors") @@ -1908,21 +2169,21 @@ class Vendor(models.Model, LocalizedNameMixin): entity = self.dealer.entity additionals = to_dict(self) if not self.vendor_model: - vendor = entity.create_vendor( - vendor_model_kwargs={ - "vendor_name": self.name, - "vendor_number": self.crn, - "address_1": self.address, - "phone": self.phone_number, - "email": self.email, - "tax_id_number": self.vrn, - "active": True, - "hidden": False, - "additional_info": additionals, - } - ) - self.vendor_model = vendor - self.save() + vendor = entity.create_vendor( + vendor_model_kwargs={ + "vendor_name": self.name, + "vendor_number": self.crn, + "address_1": self.address, + "phone": self.phone_number, + "email": self.email, + "tax_id_number": self.vrn, + "active": True, + "hidden": False, + "additional_info": additionals, + } + ) + self.vendor_model = vendor + self.save() def update_vendor_model(self): additionals = to_dict(self) @@ -1935,32 +2196,48 @@ class Vendor(models.Model, LocalizedNameMixin): self.vendor_model.additional_info = additionals self.vendor_model.save() - def create_vendor_account(self,role): + def create_vendor_account(self, role): entity = self.dealer.entity coa = entity.get_default_coa() - last_account = entity.get_all_accounts().filter(role=role).order_by('-created').first() + last_account = ( + entity.get_all_accounts().filter(role=role).order_by("-created").first() + ) if len(last_account.code) == 4: code = f"{int(last_account.code)}{1:03d}" elif len(last_account.code) > 4: - code = f"{int(last_account.code)+1}" + code = f"{int(last_account.code) + 1}" - if not entity.get_all_accounts().filter(name=self.name, role=role,coa_model=coa,balance_type="credit",active=True).exists(): + if ( + not entity.get_all_accounts() + .filter( + name=self.name, + role=role, + coa_model=coa, + balance_type="credit", + active=True, + ) + .exists() + ): entity.create_account( name=self.name, code=code, role=role, coa_model=coa, balance_type="credit", - active=True + active=True, ) + def activate_account(self): self.active = True self.vendor_model.active = True self.save() + def permenant_delete(self): self.vendor_model.delete() self.delete() + + class Payment(models.Model): METHOD_CHOICES = [ ("cash", _("cash")), @@ -1981,7 +2258,6 @@ class Payment(models.Model): ) payment_date = models.DateField(auto_now_add=True, verbose_name=_("date")) - class Meta: verbose_name = _("payment") verbose_name_plural = _("payments") @@ -2021,12 +2297,24 @@ class UserActivityLog(models.Model): def __str__(self): return f"{self.user.email} - {self.action} - {self.timestamp}" + class SaleOrder(models.Model): + STATUS_CHOICES = [ + ("PENDING_APPROVAL", "Pending Approval"), + ("APPROVED", "Approved"), + ("IN_FINANCING", "In Financing"), + ("PARTIALLY_PAID", "Partially Paid"), + ("FULLY_PAID", "Fully Paid"), + ("PENDING_DELIVERY", "Pending Delivery"), + ("DELIVERED", "Delivered"), + ("CANCELLED", "Cancelled"), + ] + estimate = models.ForeignKey( EstimateModel, on_delete=models.CASCADE, related_name="sale_orders", - verbose_name=_("Estimate") + verbose_name=_("Estimate"), ) invoice = models.ForeignKey( InvoiceModel, @@ -2034,39 +2322,170 @@ class SaleOrder(models.Model): related_name="sale_orders", verbose_name=_("Invoice"), null=True, - blank=True + blank=True, + ) + payment_method = models.CharField( + max_length=20, + choices=[ + ("cash", _("Cash")), + ("finance", _("Finance")), + ("lease", _("Lease")), + ("credit_card", _("Credit Card")), + ("bank_transfer", _("Bank Transfer")), + ("sadad", _("SADAD")), + ], ) - payment_method = models.CharField(max_length=20, choices=[ - ('cash', _('Cash')), - ('finance', _('Finance')), - ('lease', _('Lease')), - ("credit_card", _("Credit Card")), - ("bank_transfer", _("Bank Transfer")), - ("sadad", _("SADAD")), - ]) comments = models.TextField(blank=True, null=True) formatted_order_id = models.CharField(max_length=10, unique=True, editable=False) - created = models.DateTimeField(auto_now_add=True) + + # Link to the specific opportunity this sales order is fulfilling + opportunity = models.OneToOneField( + "Opportunity", # Use string reference if Opportunity is defined later or in another app + on_delete=models.CASCADE, + related_name="sales_order", + help_text="The associated sales opportunity for this order.", + ) + + # Link to the customer who is purchasing the vehicle + customer = models.ForeignKey( + "Customer", # Use string reference + on_delete=models.PROTECT, # Protect customer data if order exists + related_name="sales_orders", + help_text="The customer making the purchase.", + ) + + # Link to the specific vehicle being sold + # This assumes a Vehicle model exists which represents an actual car in inventory + car = models.ForeignKey( + "Car", # Use string reference to your Vehicle/Inventory model + on_delete=models.PROTECT, # Don't delete vehicle if it's part of an order + related_name="sales_orders", + help_text="The specific vehicle (VIN) being sold.", + null=True, + blank=True, + ) + + # Financial Details + agreed_price = models.DecimalField( + max_digits=12, + decimal_places=2, + help_text="The final agreed-upon selling price of the vehicle.", + ) + down_payment_amount = models.DecimalField( + max_digits=12, + decimal_places=2, + default=0.00, + help_text="The initial payment made by the customer.", + ) + trade_in_value = models.DecimalField( + max_digits=12, + decimal_places=2, + default=0.00, + help_text="The value of any vehicle traded in by the customer.", + ) + # Reference to the trade-in vehicle if it also exists in inventory + trade_in_vehicle = models.ForeignKey( + "Car", # Assuming Vehicle model can also represent trade-ins + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="traded_in_on_orders", + help_text="The vehicle traded in by the customer, if any.", + ) + loan_amount = models.DecimalField( + max_digits=12, + decimal_places=2, + default=0.00, + help_text="The amount financed by a bank or third-party lender.", + ) + total_paid_amount = models.DecimalField( + max_digits=12, + decimal_places=2, + default=0.00, + help_text="Sum of down payment, trade-in value, and loan amount received so far.", + ) + remaining_balance = models.DecimalField( + max_digits=12, + decimal_places=2, + default=0.00, + help_text="The remaining amount due from the customer or financing.", + ) + + # Status and Dates + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default="PENDING_APPROVAL", + help_text="Current status of the sales order.", + ) + order_date = models.DateTimeField( + default=timezone.now, help_text="The date and time the sales order was created." + ) + expected_delivery_date = models.DateField( + blank=True, null=True, help_text="The planned date for vehicle delivery." + ) + actual_delivery_date = models.DateTimeField( + blank=True, + null=True, + help_text="The actual date and time the vehicle was delivered.", + ) + cancelled_date = models.DateTimeField( + blank=True, + null=True, + help_text="The date and time the order was cancelled, if applicable.", + ) + cancellation_reason = models.TextField( + blank=True, null=True, help_text="Reason for cancellation, if applicable." + ) + + # Audit fields + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="created_sales_orders", + help_text="The user who created this sales order.", + ) + last_modified_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="modified_sales_orders", + help_text="The user who last modified this sales order.", + ) class Meta: - ordering = ['-created'] + verbose_name = "Sales Order" + verbose_name_plural = "Sales Orders" + ordering = ["-order_date"] # Order by most recent first def save(self, *args, **kwargs): if not self.formatted_order_id: - last_order = SaleOrder.objects.order_by('-id').first() + last_order = SaleOrder.objects.order_by("-id").first() if last_order: next_id = last_order.id + 1 else: next_id = 1 year = get_localdate().year self.formatted_order_id = f"O-{year}-{next_id:09d}" + self.total_paid_amount = ( + Decimal(self.down_payment_amount) + + Decimal(self.trade_in_value) + + Decimal(self.loan_amount) + ) + self.remaining_balance = Decimal(self.agreed_price) - Decimal( + self.total_paid_amount + ) super().save(*args, **kwargs) + def __str__(self): - return f"Sale Order for {self.full_name}" + return f"Sales Order #{self.formatted_order_id} for {self.customer.first_name} " @property def full_name(self): - return f"{self.customer.customer_name}" + return f"{self.customer.first_name} {self.customer.last_name}" @property def price(self): @@ -2079,13 +2498,16 @@ class SaleOrder(models.Model): return [] @property - def customer(self): - return self.estimate.customer + def cars(self): + return [x.car for x in self.estimate.get_itemtxs_data()[0]] + class CustomGroup(models.Model): name = models.CharField(max_length=100) dealer = models.ForeignKey(Dealer, on_delete=models.CASCADE, related_name="groups") - group = models.OneToOneField("auth.Group", verbose_name=_("Group"), on_delete=models.CASCADE) + group = models.OneToOneField( + "auth.Group", verbose_name=_("Group"), on_delete=models.CASCADE + ) @property def users(self): @@ -2118,28 +2540,126 @@ class CustomGroup(models.Model): def set_default_permissions(self): self.clear_permissions() if self.name == "Manager": - self.set_permissions(app="inventory",allowed_models=["car","carfinance","carlocation","customcard","cartransfer","carcolors","carequipment","interiorcolors","exteriorcolors","carreservation"]) - self.set_permissions(app="inventory",allowed_models=["lead","customgroup","saleorder","payment","staff","schedule","activity","opportunity"]) - self.set_permissions(app="django_ledger",allowed_models=["estimatemodel","invoicemodel","accountmodel","chartofaccountmodel","customermodel","billmodel"]) + self.set_permissions( + app="inventory", + allowed_models=[ + "car", + "carfinance", + "carlocation", + "customcard", + "cartransfer", + "carcolors", + "carequipment", + "interiorcolors", + "exteriorcolors", + "carreservation", + ], + ) + self.set_permissions( + app="inventory", + allowed_models=[ + "lead", + "customgroup", + "saleorder", + "payment", + "staff", + "schedule", + "activity", + "opportunity", + ], + ) + self.set_permissions( + app="django_ledger", + allowed_models=[ + "estimatemodel", + "invoicemodel", + "accountmodel", + "chartofaccountmodel", + "customermodel", + "billmodel", + ], + ) elif self.name == "Inventory": - self.set_permissions(app="inventory",allowed_models=["car","carequipment","interiorcolors","exteriorcolors","carcolors","carlocation","customcard","carreservation"]) + self.set_permissions( + app="inventory", + allowed_models=[ + "car", + "carequipment", + "interiorcolors", + "exteriorcolors", + "carcolors", + "carlocation", + "customcard", + "carreservation", + ], + ) elif self.name == "Sales": - self.set_permissions(app="django_ledger",allowed_models=["estimatemodel","invoicemodel","customermodel"]) + self.set_permissions( + app="django_ledger", + allowed_models=["estimatemodel", "invoicemodel", "customermodel"], + ) - self.set_permissions(app="inventory",allowed_models=["saleorder","payment","staff","schedule","activity","opportunity","customer","organization"]) - self.set_permissions(app="inventory",allowed_models=["lead","salequotation","salequotationcar"], - other_perms=['view_car','view_carlocation','view_customcard','view_carcolors','view_cartransfer']) + self.set_permissions( + app="inventory", + allowed_models=[ + "saleorder", + "payment", + "staff", + "schedule", + "activity", + "opportunity", + "customer", + "organization", + ], + ) + self.set_permissions( + app="inventory", + allowed_models=["lead", "salequotation", "salequotationcar"], + other_perms=[ + "view_car", + "view_carlocation", + "view_customcard", + "view_carcolors", + "view_cartransfer", + ], + ) elif self.name == "Accountant": - self.set_permissions(app="inventory",allowed_models=["carfinance"],other_perms=['view_car','view_carlocation','view_customcard','view_carcolors','view_cartransfer','view_saleorder']) - self.set_permissions(app="django_ledger",allowed_models=["bankaccountmodel","accountmodel","chartofaccountmodel","customcard","billmodel","itemmodel","invoicemodel","vendormodel","journalentrymodel"],other_perms=['view_customermodel','view_estimatemodel']) + self.set_permissions( + app="inventory", + allowed_models=["carfinance"], + other_perms=[ + "view_car", + "view_carlocation", + "view_customcard", + "view_carcolors", + "view_cartransfer", + "view_saleorder", + ], + ) + self.set_permissions( + app="django_ledger", + allowed_models=[ + "bankaccountmodel", + "accountmodel", + "chartofaccountmodel", + "customcard", + "billmodel", + "itemmodel", + "invoicemodel", + "vendormodel", + "journalentrymodel", + ], + other_perms=["view_customermodel", "view_estimatemodel"], + ) elif self.name == "Agent": # Todo : set permissions for agent pass - - def set_permissions(self,app="inventory", allowed_models=[],other_perms=[]): + def set_permissions(self, app="inventory", allowed_models=[], other_perms=[]): try: - for perm in Permission.objects.filter(content_type__app_label=app,content_type__model__in=allowed_models): + for perm in Permission.objects.filter( + content_type__app_label=app, content_type__model__in=allowed_models + ): self.add_permission(perm) for perm in other_perms: p = Permission.objects.get(codename=perm) @@ -2149,19 +2669,58 @@ class CustomGroup(models.Model): class DealerSettings(models.Model): - dealer = models.OneToOneField(Dealer, on_delete=models.CASCADE, related_name="settings",null=True, blank=True) - invoice_cash_account = models.ForeignKey(AccountModel,related_name="invoice_cash", on_delete=models.SET_NULL, null=True, blank=True) - invoice_prepaid_account = models.ForeignKey(AccountModel,related_name="invoice_prepaid", on_delete=models.SET_NULL, null=True, blank=True) - invoice_unearned_account = models.ForeignKey(AccountModel,related_name="invoice_unearned", on_delete=models.SET_NULL, null=True, blank=True) + dealer = models.OneToOneField( + Dealer, on_delete=models.CASCADE, related_name="settings", null=True, blank=True + ) + invoice_cash_account = models.ForeignKey( + AccountModel, + related_name="invoice_cash", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + invoice_prepaid_account = models.ForeignKey( + AccountModel, + related_name="invoice_prepaid", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + invoice_unearned_account = models.ForeignKey( + AccountModel, + related_name="invoice_unearned", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) - bill_cash_account = models.ForeignKey(AccountModel,related_name="bill_cash", on_delete=models.SET_NULL, null=True, blank=True) - bill_prepaid_account = models.ForeignKey(AccountModel,related_name="bill_prepaid", on_delete=models.SET_NULL, null=True, blank=True) - bill_unearned_account = models.ForeignKey(AccountModel,related_name="bill_unearned", on_delete=models.SET_NULL, null=True, blank=True) - additional_info = models.JSONField(default=dict,null=True,blank=True) + bill_cash_account = models.ForeignKey( + AccountModel, + related_name="bill_cash", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + bill_prepaid_account = models.ForeignKey( + AccountModel, + related_name="bill_prepaid", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + bill_unearned_account = models.ForeignKey( + AccountModel, + related_name="bill_unearned", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + additional_info = models.JSONField(default=dict, null=True, blank=True) def __str__(self): return f"Settings for {self.dealer}" + # class customPlan(AbstractPlan): # default = models.BooleanField( # help_text=_('Both "Unknown" and "No" means that the plan is not default'), diff --git a/inventory/urls.py b/inventory/urls.py index 0adbc6c5..b8cfc8a0 100644 --- a/inventory/urls.py +++ b/inventory/urls.py @@ -125,7 +125,7 @@ urlpatterns = [ name="add_note", ), path( - "crm//update-task/", + "crm///update-task/", views.update_task, name="update_task", ), @@ -598,6 +598,7 @@ path( "sales/estimates//send_email", views.send_email_view, name="send_email" ), path('sales/estimates//sale_order/', views.create_sale_order, name='create_sale_order'), + path('sales/estimates//sale_order//details/', views.SaleOrderDetail.as_view(), name='sale_order_details'), path('sales/estimates//sale_order/preview/', views.preview_sale_order, name='preview_sale_order'), # Invoice diff --git a/inventory/utils.py b/inventory/utils.py index 9bccf5bc..384946b8 100644 --- a/inventory/utils.py +++ b/inventory/utils.py @@ -27,6 +27,11 @@ from django.utils.translation import get_language from appointment.models import StaffMember from django.contrib.auth.models import User +import secrets + +def make_random_password(length=10, allowed_chars='abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'): + return ''.join(secrets.choice(allowed_chars) for i in range(length)) + def get_jwt_token(): """ Fetches a JWT token from an external authentication API. @@ -999,13 +1004,13 @@ class CarFinanceCalculator: car_finance = self._get_nested_value(item, self.CAR_FINANCE_KEY) car_info = self._get_nested_value(item, self.CAR_INFO_KEY) unit_price = Decimal(car_finance.get('selling_price', 0)) - return { "item_number": item.item_model.item_number, "vin": car_info.get('vin'), "make": car_info.get('make'), "model": car_info.get('model'), "year": car_info.get('year'), + "logo": item.item_model.car.id_car_make.logo.url, "trim": car_info.get('trim'), "mileage": car_info.get('mileage'), "cost_price": car_finance.get('cost_price'), diff --git a/inventory/views.py b/inventory/views.py index 35dabf24..ecc13651 100644 --- a/inventory/views.py +++ b/inventory/views.py @@ -3912,7 +3912,13 @@ def create_sale_order(request, pk): if request.method == "POST": form = forms.SaleOrderForm(request.POST) if form.is_valid(): - form.save() + instance = form.save(commit=False) + instance.estimate = estimate + instance.customer = estimate.customer.customer_set.first() + instance.created_by = request.user + instance.last_modified_by = request.user + instance.save() + if not estimate.is_approved(): estimate.mark_as_approved() estimate.save() @@ -3925,8 +3931,11 @@ def create_sale_order(request, pk): dealer = get_user_type(request) item.item_model.car.mark_as_sold() - # models.Activity.objects.create(dealer=dealer,content_object=item.item_model.car, notes="Car Sold",created_by=request.user,activity_type=models.ActionChoices.SALE_CAR) + return redirect("estimate_detail", pk=estimate.pk) + # models.Activity.objects.create(dealer=dealer,content_object=item.item_model.car, notes="Car Sold",created_by=request.user,activity_type=models.ActionChoices.SALE_CAR) + else: + print(form.errors) messages.success(request, "Sale Order created successfully") return redirect("estimate_detail", pk=estimate.pk) @@ -3938,10 +3947,30 @@ def create_sale_order(request, pk): finance_data = calculator.get_finance_data() return render( request, - "sales/estimates/sale_order_form.html", + "sales/estimates/sale_order_form1.html", {"form": form, "estimate": estimate, "items": items, "data": finance_data}, ) +class SaleOrderDetail(DetailView): + model = models.SaleOrder + template_name = "sales/orders/order_details.html" + context_object_name = "saleorder" + + def get_object(self, queryset=None): + order_pk = self.kwargs.get('order_pk') + return models.SaleOrder.objects.get( + pk=order_pk, + ) + + def get_context_data(self, **kwargs): + saleorder = kwargs.get("object") + estimate = saleorder.estimate + if estimate.get_itemtxs_data(): + calculator = CarFinanceCalculator(estimate) + finance_data = calculator.get_finance_data() + kwargs["data"] = finance_data + return super().get_context_data(**kwargs) + @login_required def preview_sale_order(request, pk): @@ -4408,9 +4437,12 @@ def invoice_create(request, pk): commit=True, operation=InvoiceModel.ITEMIZE_APPEND, ) + sale_order = estimate.sale_orders.first() + sale_order.invoice = invoice invoice.bind_estimate(estimate) invoice.mark_as_review() estimate.mark_as_completed() + sale_order.save() estimate.save() invoice.save() messages.success(request, "Invoice created successfully") @@ -4730,7 +4762,7 @@ class LeadListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): def get_queryset(self): query = self.request.GET.get("q") dealer = get_user_type(self.request) - qs = models.Lead.objects.filter(dealer=dealer) + qs = models.Lead.objects.filter(dealer=dealer).exclude(status="converted") if query: qs = apply_search_filters(qs, query) if self.request.is_dealer: @@ -4834,9 +4866,10 @@ def lead_create(request): email=instance.email, first_name=instance.first_name, last_name=instance.last_name, + active=False, ) - customer.create_user_model() - customer.create_customer_model() + customer.create_user_model(for_lead=True) + customer.create_customer_model(for_lead=True) customer.save() instance.customer = customer @@ -4851,12 +4884,13 @@ def lead_create(request): phone_number=instance.phone_number, email=instance.email, name=instance.first_name + " " + instance.last_name, + active=False, ) - organization.create_user_model() - organization.create_customer_model() + organization.create_user_model(for_lead=True) + organization.create_customer_model(for_lead=True) organization.save() instance.organization = organization - instance.next_action = LeadStatus.FOLLOW_UP + instance.next_action = LeadStatus.NEW instance.save() messages.success(request, _("Lead created successfully")) return redirect("lead_list") @@ -4926,7 +4960,7 @@ def update_lead_actions(request): current_action = request.POST.get("current_action") next_action = request.POST.get("next_action") next_action_date = request.POST.get("next_action_date", None) - print(request.POST) + if not all([lead_id, current_action, next_action]): return JsonResponse( {"success": False, "message": "All fields are required"}, status=400 @@ -5076,12 +5110,14 @@ def add_note_to_opportunity(request, slug): :return: A redirect response to the detailed view of the opportunity. """ opportunity = get_object_or_404(models.Opportunity, slug=slug) + dealer = get_user_type(request) if request.method == "POST": notes = request.POST.get("notes") if not notes: messages.error(request, _("Notes field is required")) else: models.Notes.objects.create( + dealer=dealer, content_object=opportunity, created_by=request.user, note=notes ) messages.success(request, _("Note added successfully")) @@ -5426,14 +5462,20 @@ class OpportunityCreateView(CreateView, SuccessMessageMixin, LoginRequiredMixin) if self.kwargs.get("slug", None): lead = models.Lead.objects.get(slug=self.kwargs.get("slug"), dealer=dealer) initial["lead"] = lead - initial["stage"] = models.Stage.PROPOSAL + initial["stage"] = models.Stage.QUALIFICATION return initial def form_valid(self, form): dealer = get_user_type(self.request) - form.instance.dealer = dealer - form.instance.customer = form.instance.lead.customer - form.instance.staff = form.instance.lead.staff + instance = form.save(commit=False) + if self.kwargs.get("slug"): + lead = models.Lead.objects.get(slug=self.kwargs.get("slug"), dealer=dealer) + instance.dealer = dealer + instance.staff = lead.staff + instance.lead = lead + lead.convert_to_customer() + lead.save() + instance.save() return super().form_valid(form) def get_success_url(self): @@ -5501,12 +5543,21 @@ class OpportunityDetailView(LoginRequiredMixin, DetailView): form.fields["stage"].widget.attrs["hx-get"] = url form.fields["stage"].initial = self.object.stage context["status_form"] = form + context["lead_notes"] = models.Notes.objects.filter( + content_type__model="lead", object_id=self.object.id + ).order_by("-created") context["notes"] = models.Notes.objects.filter( content_type__model="opportunity", object_id=self.object.id ).order_by("-created") + context["lead_activities"] = models.Activity.objects.filter( + content_type__model="lead", object_id=self.object.id + ) context["activities"] = models.Activity.objects.filter( content_type__model="opportunity", object_id=self.object.id ) + lead_email_qs = models.Email.objects.filter( + content_type__model="lead", object_id=self.object.id + ) email_qs = models.Email.objects.filter( content_type__model="opportunity", object_id=self.object.id ) @@ -5514,6 +5565,17 @@ class OpportunityDetailView(LoginRequiredMixin, DetailView): "sent": email_qs.filter(status="SENT"), "draft": email_qs.filter(status="DRAFT"), } + context["lead_emails"] = { + "sent": lead_email_qs.filter(status="SENT"), + "draft": lead_email_qs.filter(status="DRAFT"), + } + context["staff_task_form"] = forms.StaffTaskForm() + context["lead_tasks"] = models.Tasks.objects.filter( + content_type__model="lead", object_id=self.object.id + ) + context["tasks"] = models.Tasks.objects.filter( + content_type__model="opportunity", object_id=self.object.id + ) return context @@ -8082,21 +8144,22 @@ def add_task(request, content_type, slug): return redirect(f"{content_type}_detail", slug=slug) -def update_task(request, pk): +def update_task(request,content_type, pk): + try: + model = apps.get_model(f"inventory.{content_type}") + except LookupError: + raise Http404("Model not found") + task = get_object_or_404(models.Tasks, pk=pk) - lead = get_object_or_404(models.Lead, pk=task.content_object.id) + obj = get_object_or_404(model, pk=task.content_object.id) if request.method == "POST": task.completed = False if task.completed else True task.save() - messages.success(request, _("Task updated successfully")) - else: - messages.error(request, _("Task form is not valid")) - # response = HttpResponse() - # response['HX-Refresh'] = 'true' - # return response - tasks = models.Tasks.objects.filter(content_type__model="lead", object_id=lead.id) - return render(request, "crm/leads/lead_detail.html", {"lead": lead, "tasks": tasks}) + + # tasks = models.Tasks.objects.filter(content_type__model=content_type, object_id=obj.id) + + return render(request, "partials/task.html", {"task": task}) def add_note(request, content_type, slug): diff --git a/templates/components/task_modal.html b/templates/components/task_modal.html new file mode 100644 index 00000000..58fe158d --- /dev/null +++ b/templates/components/task_modal.html @@ -0,0 +1,21 @@ +{% load static i18n crispy_forms_tags %} + + diff --git a/templates/crm/leads/lead_detail.html b/templates/crm/leads/lead_detail.html index 0e3aac82..30baf6e8 100644 --- a/templates/crm/leads/lead_detail.html +++ b/templates/crm/leads/lead_detail.html @@ -38,60 +38,7 @@ {% block content %}
- - + {% include "crm/leads/partials/update_action.html" %}

{{ _("Lead Details")}}

@@ -130,16 +77,14 @@
{{ _("Status")}} {% if lead.status == "new" %} {{_("New")}} - {% elif lead.status == "follow_up" %} - {{_("Follow Up")}} - {% elif lead.status == "negotiation" %} - {{_("Negotiation")}} - {% elif lead.status == "won" %} - {{_("Won")}} - {% elif lead.status == "lost" %} - {{_("Lost")}} - {% elif lead.status == "closed" %} - {{_("Closed")}} + {% elif lead.status == "contacted" %} + {{_("Contacted")}} + {% elif lead.status == "qualified" %} + {{_("Qualified")}} + {% elif lead.status == "unqualified" %} + {{_("Unqualified")}} + {% elif lead.status == "converted" %} + {{_("Converted")}} {% endif %}
@@ -149,9 +94,10 @@
-
-
{{ _("Car Requested") }}
- {{ lead.id_car_make.get_local_name }} - {{ lead.id_car_model.get_local_name }} {{ lead.year }} +
+
{{ _("Car Requested") }}
+ Car Make Logo +

{{ lead.id_car_make.get_local_name }} - {{ lead.id_car_model.get_local_name }} {{ lead.year }}

@@ -170,12 +116,6 @@
{{ lead.phone_number}}
-
-
{{CURRENCY}}  -
{{ _("Salary")}}
-
-

{{CURRENCY}} {{lead.salary}}

-
{{ _("Created")}}
@@ -200,12 +140,6 @@
{{ lead.address}}
-
-
-
{{ _("City") }}
-
- {{ lead.city }} -
@@ -218,12 +152,12 @@
{{lead.next_action|capfirst}}
  {% trans "Next Action" %} :  {{lead.next_action_date|naturalday|capfirst}}
-
+
-

{{ _("Activities") }} ({{ activities.count}})

+

{{ _("Activities") }} ({{ activities.count}})

@@ -300,6 +234,38 @@
+
+
+

{{ _("Opportunities") }} ({{ lead.get_opportunities.count}})

+ {{ _("Add Opportunity") }} +
+ +
+
+ + + + + + + + + + + {% for opportunity in lead.get_opportunities %} + + + + + + + {% endfor %} + +
{{ _("Car") }}{{ _("Probability")}}{{ _("Priority")}}
{{opportunity.car}}{{opportunity.probability}}{{opportunity.priority|capfirst}}View
+ +
+
+

{{ _("Notes") }}

@@ -319,7 +285,6 @@ {% for note in notes %} - {{note.note}} {% if note.created_by.staff %} @@ -502,12 +467,12 @@ Completed - + {% for task in tasks %}
- +
{{task.title}} diff --git a/templates/crm/leads/lead_list.html b/templates/crm/leads/lead_list.html index 6081489f..15157090 100644 --- a/templates/crm/leads/lead_list.html +++ b/templates/crm/leads/lead_list.html @@ -6,60 +6,7 @@

{{ _("Leads")|capfirst }}

- + {% include "crm/leads/partials/update_action.html" %}
@@ -201,15 +148,15 @@ {% if schedule.scheduled_type == "call" %} - {{ schedule.scheduled_at|naturalday|capfirst }} + {{ schedule.scheduled_at|naturaltime|capfirst }} {% elif schedule.scheduled_type == "meeting" %} - {{ schedule.scheduled_at|naturalday|capfirst }} + {{ schedule.scheduled_at|naturaltime|capfirst }} {% elif schedule.scheduled_type == "email" %} - {{ schedule.scheduled_at|naturalday|capfirst }} + {{ schedule.scheduled_at|naturaltime|capfirst }} {% endif %} diff --git a/templates/crm/leads/partials/update_action.html b/templates/crm/leads/partials/update_action.html new file mode 100644 index 00000000..ef825ad6 --- /dev/null +++ b/templates/crm/leads/partials/update_action.html @@ -0,0 +1,53 @@ + diff --git a/templates/crm/opportunities/opportunity_detail.html b/templates/crm/opportunities/opportunity_detail.html index 27d4287c..d06821c1 100644 --- a/templates/crm/opportunities/opportunity_detail.html +++ b/templates/crm/opportunities/opportunity_detail.html @@ -19,6 +19,7 @@ {% endif %}
  • Update Opportunity
  • +
  • Update Stage
  • Delete Opportunity
  • @@ -32,9 +33,9 @@
    {% if opportunity.car %} -

    {{ opportunity.car.id_car_make.get_local_name }} - {{ opportunity.car.id_car_model.get_local_name }} - {{ opportunity.car.year }}

    +

    {{ opportunity.car.id_car_make.get_local_name }} - {{ opportunity.car.id_car_model.get_local_name }} - {{ opportunity.car.year }}

    {% endif %} -

    {{ opportunity.customer.customer_name }}

    +
    {% if opportunity.car.finances %}
    {{ opportunity.car.finances.total }} {{ CURRENCY }}
    @@ -48,60 +49,108 @@ {% endif %}
    -
    {{ opportunity.staff.get_local_name}}
    - -
    {{ opportunity.get_stage_display }}{{ opportunity.get_status_display }}
    +
    STAGE : {{ opportunity.get_stage_display }}{{ opportunity.get_status_display }}

    {{ opportunity.get_status_display }}

    -
    {{ opportunity.created}}
    +
    {{ opportunity.created|naturaltime|capfirst}}
    - {% comment %}
    +
    -

    {{ _("Other Information")}}

    +

    {{ _("Upcoming Events")}}

    -
    {{ _("Status") }}
    - {{ _("Update Status")}} +
    {{ _("Estimate") }}
    - {{status_form.status}} + {% if opportunity.estimate %} + {{ _("View Quotation")}} + {% else %} +

    {{ _("No Estimate") }}

    + {% endif %}
    -
    {{ _("Stage") }}
    - {{ _("Update Stage")}} +
    {{ _("Invoice") }}
    - {{status_form.stage}} + {% if opportunity.estimate.invoice %} + {{ _("View Invoice")}} + {% else %} +

    {{ _("No Invoice") }}

    + {% endif %}
    -
    {% endcomment %} +
    +
    +
    +

    {{ _("Related Records")}}

    +
    +
    +
    +
    +
    {{ _("Estimate") }}
    +
    + {% if opportunity.estimate %} + {{ _("View Quotation")}} + {% else %} +

    {{ _("No Estimate") }}

    + {% endif %} +
    +
    +
    +
    {{ _("Invoice") }}
    +
    + {% if opportunity.estimate.invoice %} + {{ _("View Invoice")}} + {% else %} +

    {{ _("No Invoice") }}

    + {% endif %} +
    +
    +
    +
    +
    +
    +
    +

    {{ _("System Information")}}

    +
    +
    +
    +
    +
    {{ _("Created ") }}
    +
    + {{ opportunity.created|naturalday|capfirst }} +
    +
    +
    +
    {{ _("Last Updated") }}
    +
    +
    + {{ opportunity.updated }} +
    +
    +
    +
    @@ -110,7 +159,7 @@
    -
    +

    {{ _("Quotation Amount") }}

    @@ -123,19 +172,19 @@

    -
    +
    {{CURRENCY}}
    -

    Code

    -

    PHO1234

    +

    {{ _("Amount") }}

    +

    {{opportunity.amount}}

    -
    +
    {{CURRENCY}}
    -

    Type

    -

    New Business

    +

    {{ _("Expected Revenue") }}

    +

    {{opportunity.expected_revenue}}

    @@ -160,13 +209,13 @@ : -

    {{ opportunity.probability }}

    +

    {{ opportunity.probability }} (%)

    -
    +
    {{CURRENCY}}

    {{ _("Estimated Revenue") }}

    @@ -192,7 +241,7 @@
    : - {{ opportunity.customer.phone }} + {{ opportunity.customer.phone_number }} @@ -222,19 +271,27 @@ : -
    {{ opportunity.customer.get_full_name}}
    + {% if opportunity.customer %} +
    {{ opportunity.customer.full_name}}
    + {% else %} +
    {{ opportunity.organization}}
    + {% endif %}
    -

    {{ _("Staff") }}

    +

    {{ _("Assigned To") }}

    : -
    {{ opportunity.staff.get_local_name}}
    + {% if request.user.email == opportunity.staff.email %} +
    You
    + {% else %} +
    {{ opportunity.staff.get_local_name}}
    + {% endif %} @@ -255,19 +312,19 @@ : -
    {{ opportunity.created|date}}
    +
    {{ opportunity.created|naturaltime|capfirst}}
    -

    {{ _("Closing Date")}}

    +

    {{ _("Expected Closing Date")}}

    : -
    {{ opportunity.closing_date|date}}
    +
    {{ opportunity.expected_close_date|date}}
    @@ -275,12 +332,14 @@
    @@ -300,6 +359,43 @@
    + {% for activity in lead_activities %} +
    +
    +
    + {% if activity.activity_type == "call" %} + + {% elif activity.activity_type == "email" %} + + {% elif activity.activity_type == "meeting" %} + + {% elif activity.activity_type == "whatsapp" %} + + {% endif %} +
    +
    +
    +
    +
    +

    {{activity.notes}}

    +
    +
    +
    +
    +
    + {% if request.user.email == activity.created_by %} +

    by You

    + {% else %} +

    by{{activity.created_by}}

    + {% endif %} +
    +
    {{activity.created|naturalday|capfirst}}
    +
    +

    +
    +
    +
    + {% endfor %} {% for activity in activities %}
    @@ -318,7 +414,17 @@
    -

    by{{activity.created_by}}

    +

    {{activity.notes}}

    +
    +
    +
    +
    +
    + {% if request.user.email == activity.created_by %} +

    by You

    + {% else %} +

    by{{activity.created_by}}

    + {% endif %}
    {{activity.created|naturalday|capfirst}}
    @@ -337,11 +443,20 @@
    + {% for note in lead_notes %} +
    +

    {{ note.note }}

    +
    +
    {{note.created|naturaltime|capfirst}}
    +

    by{{note.created_by}}

    +
    +
    + {% endfor %} {% for note in notes %}

    {{ note.note }}

    -
    {{note.created}}
    +
    {{note.created|naturaltime|capfirst}}

    by{{note.created_by}}

    @@ -412,7 +527,7 @@ {{call.purpose}} {{call.scheduled_by}} - {{call.created_at}} + {{call.created_at|naturaltime|capfirst}}
    @@ -507,6 +622,51 @@
    +
    +
    +

    {{ _("Tasks") }}

    + +
    +
    + +
    +
    + + + + + + + + + + + + + {% for task in tasks %} + {% include "partials/task.html" with task=task content_type="opportunity" %} + {% endfor %} + +
    +
    + +
    +
    TitleAssigned toDue DateCompleted
    +
    +
    + +
    + +
      + +
      +
      +
      +
      + +

      Attachments

      @@ -554,4 +714,5 @@
      {% include "components/activity_modal.html" with content_type="opportunity" slug=opportunity.slug %} + {% include "components/task_modal.html" with content_type="opportunity" slug=opportunity.slug %} {% endblock %} \ No newline at end of file diff --git a/templates/crm/opportunities/opportunity_form.html b/templates/crm/opportunities/opportunity_form.html index a7fd8cb2..1f554cb2 100644 --- a/templates/crm/opportunities/opportunity_form.html +++ b/templates/crm/opportunities/opportunity_form.html @@ -81,6 +81,22 @@
      {% endif %}
      + +
      + +
      + {{CURRENCY}} + {{ form.amount|add_class:"form-control" }} +
      + {% if form.amount.errors %} +
      + {{ form.amount.errors }} +
      + {% endif %} +
      @@ -107,7 +123,7 @@ {% endif %}
      - +
      +
      - {{ form.closing_date|add_class:"form-control" }} + {{ form.expected_close_date|add_class:"form-control" }}
      - {% if form.closing_date.errors %} + {% if form.expected_close_date.errors %}
      - {{ form.closing_date.errors }} + {{ form.expected_close_date.errors }}
      {% endif %}
      @@ -188,6 +205,10 @@ +{% endblock customJS %} \ No newline at end of file diff --git a/templates/sales/orders/order_details.html b/templates/sales/orders/order_details.html new file mode 100644 index 00000000..c1f48912 --- /dev/null +++ b/templates/sales/orders/order_details.html @@ -0,0 +1,619 @@ +{% extends "base.html" %} +{% load static i18n humanize %} + +{% block customCSS %} + +{% endblock customCSS %} + +{% block content %} +
      + +
      +
      +
      +

      + + Sale Order #{{ saleorder.formatted_order_id }} +

      +
      + + +
      +
      +
      +
      + + +
      +
      + +
      + +
      +
      +
      Order Summary
      + + {{ saleorder.get_status_display }} + +
      +
      +
      +
      +
      + +

      {{ saleorder.order_date|date }}

      +
      +
      + +

      {{ saleorder.customer.full_name|capfirst }}

      +
      +
      + +

      {{ saleorder.get_payment_method_display }}

      +
      +
      + +

      {{ saleorder.created_by }}

      +
      +
      +
      +
      + +

      + {% if saleorder.expected_delivery_date %} + {{ saleorder.expected_delivery_date|date }} + {% else %} + Not scheduled + {% endif %} +

      +
      +
      + +

      + {{ saleorder.updated_at|naturaltime|capfirst }} by + {{ saleorder.last_modified_by }} +

      +
      + {% if saleorder.status == 'cancelled' %} +
      + +

      {{ saleorder.cancellation_reason|default:"Not specified" }}

      +
      + {% endif %} +
      +
      + {% if saleorder.comments %} +
      + +
      +

      {{ saleorder.comments }}

      +
      +
      + {% endif %} +
      +
      + + +
      +
      +
      Vehicle Details
      +
      +
      +
      + {% if data.cars %} + {% for car in data.cars %} +
      + Vehicle +
      +
      +
      +
      + +

      {{ car.make }}

      +
      +
      + +

      {{ car.model }}

      +
      +
      + +

      {{ car.year }}

      +
      +
      + +

      {{ car.vin }}

      +
      +
      + +

      {{ car.mileage|intcomma }} km

      +
      +
      +
      +
      + {% endfor %} + {% else %} +
      +

      No vehicle assigned to this order

      +
      + {% endif %} +
      +
      +
      + + +
      +
      +
      Financial Details
      +
      +
      +
      +
      +
      + +

      SAR {{ saleorder.agreed_price|intcomma }}

      +
      +
      + +

      SAR {{ saleorder.down_payment_amount|intcomma }}

      +
      +
      + +

      SAR {{ saleorder.trade_in_value|intcomma }}

      +
      +
      +
      +
      + +

      SAR {{ saleorder.loan_amount|intcomma }}

      +
      +
      + +

      SAR {{ saleorder.total_paid_amount|intcomma }}

      +
      +
      + +

      + SAR {{ saleorder.remaining_balance|intcomma }} +

      +
      +
      +
      +
      + {% widthratio saleorder.total_paid_amount saleorder.agreed_price 100 as payment_percentage %} +
      +
      +
      + {{ payment_percentage }}% Paid + SAR {{ saleorder.agreed_price|intcomma }} Total +
      +
      +
      + + +
      +
      +
      Documents
      + +
      +
      +
      + +

      Drag & drop files here or click to browse

      +

      PDF, JPG, PNG up to 10MB

      +
      +
      + {% for document in saleorder.documents.all %} +
      +
      + {% if document.file.url|lower|slice:'-3:' == 'pdf' %} + PDF Document + {% else %} + Document + {% endif %} +
      +

      {{ document.get_filename }}

      +

      Added: {{ document.created_at|date:"F j, Y" }}

      +
      +
      +
      + {% empty %} +
      +

      No documents uploaded yet

      +
      + {% endfor %} +
      +
      +
      + + +
      +
      +
      Comments & Notes
      +
      +
      + {% comment %}
      {% endcomment %} + + {% csrf_token %} +
      + +
      + +
      +
      +
      +
      + {% for comment in saleorder.comments.all %} +
      +
      +
      +
      + {{ comment.created_by.get_full_name|default:comment.created_by.username }} + {{ comment.created_at|date:"F j, Y H:i A" }} +
      +

      {{ comment.text }}

      +
      +
      +
      + {% empty %} +
      +

      No comments yet

      +
      + {% endfor %} +
      +
      +
      +
      + + +
      + +
      +
      +
      Order Actions
      +
      +
      +
      + {% if saleorder.status == 'pending_approval' %} + + {% endif %} + + {% comment %} {% endcomment %} + + Edit Order + + + {% if not saleorder.invoice %} + {% comment %} {% endcomment %} + + Create Invoice + + {% endif %} + + {% if saleorder.status == 'approved' and not saleorder.actual_delivery_date %} + + {% endif %} + + {% if saleorder.status != 'cancelled' %} + + {% endif %} +
      +
      +
      + + +
      +
      +
      Order Status Timeline
      +
      +
      +
      + {% for log in saleorder.status_logs.all %} +
      +
      + {{ log.get_status_display }} + {{ log.created_at|date:"F j, Y" }} +
      +

      + {% if log.note %}{{ log.note }}{% endif %} +
      + Changed by: {{ log.changed_by.get_full_name|default:log.changed_by.username }} +

      +
      + {% empty %} +
      +

      No status history available

      +
      + {% endfor %} +
      +
      +
      + + +
      +
      +
      Related Items
      +
      +
      + +
      + +

      + {% if saleorder.invoice %} + +

      + {{ saleorder.invoice.invoice_number }} +

      + + {% else %} + Not created yet + {% endif %} +

      +
      + + +
      +
      + + + {% if saleorder.trade_in_vehicle %} +
      +
      +
      Trade-In Vehicle
      +
      +
      +
      + Trade-In Vehicle +
      + {{ saleorder.trade_in_vehicle.year }} + {{ saleorder.trade_in_vehicle.make }} + {{ saleorder.trade_in_vehicle.model }} +
      +

      VIN: {{ saleorder.trade_in_vehicle.vin }}

      +

      SAR {{ saleorder.trade_in_value|intcomma }}

      +
      +
      +
      +

      + + {{ saleorder.trade_in_vehicle.mileage|intcomma }} km +

      +
      +
      +

      + + {{ saleorder.trade_in_vehicle.color }} +

      +
      +
      +

      + + {{ saleorder.trade_in_vehicle.engine }} +

      +
      +
      +

      + + {{ saleorder.trade_in_vehicle.get_transmission_display }} +

      +
      +
      +
      +
      + {% endif %} +
      +
      +
      +
      + + + + + + +{% endblock content %} + +{% block customJS %} + +{% endblock customJS %} \ No newline at end of file diff --git a/templates/sales/orders/order_list.html b/templates/sales/orders/order_list.html index 54b47220..81ba605b 100644 --- a/templates/sales/orders/order_list.html +++ b/templates/sales/orders/order_list.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% load i18n static %} +{% load i18n static humanize %} {% block title %}{{ _("Orders") }}{% endblock title %} @@ -14,6 +14,9 @@ {% trans "Order Number" %} {% trans "Customer" %} {% trans "For Quotation" %} + {% trans "Invoice" %} + {% trans "Status" %} + {% trans "Expected Delivery" %} @@ -24,9 +27,21 @@ {{ order.formatted_order_id }} {{ order.estimate.customer.customer_name }} - - {{ order.estimate }} - + + {{ order.estimate }} + + + + {% if order.invoice %} + + {{ order.invoice }} + + {% endif %} + + {{ order.status }} + {{ order.expected_delivery_date|naturalday|capfirst }} + + View {% empty %}