diff --git a/inventory/forms.py b/inventory/forms.py index 5a57f0f9..7d78c4e7 100644 --- a/inventory/forms.py +++ b/inventory/forms.py @@ -17,6 +17,7 @@ from .models import ( Dealer, # Branch, Vendor, + Schedule, Customer, Car, CarTransfer, @@ -38,7 +39,7 @@ from .models import ( SaleOrder ) from django_ledger.models import ItemModel, InvoiceModel, BillModel,VendorModel -from django.forms import ModelMultipleChoiceField, ValidationError, DateInput +from django.forms import ModelMultipleChoiceField, ValidationError, DateInput,DateTimeInput from django.utils.translation import gettext_lazy as _ import django_tables2 as tables from django.forms import formset_factory @@ -646,8 +647,12 @@ class EmailForm(forms.Form): class LeadForm(forms.ModelForm): class Meta: model = Lead - fields = ['customer', - 'city', + fields = [ + 'first_name', + 'last_name', + 'email', + 'phone_number', + 'address', 'id_car_make', 'id_car_model', 'year', @@ -666,6 +671,11 @@ class LeadForm(forms.ModelForm): (obj.id_car_make, obj.get_local_name()) for obj in queryset ] +class ScheduleForm(forms.ModelForm): + scheduled_at = forms.DateTimeField(widget=DateTimeInput(attrs={'type': 'datetime-local'})) + class Meta: + model = Schedule + fields = ['purpose','scheduled_type', 'scheduled_at', 'notes'] class NoteForm(forms.ModelForm): class Meta: diff --git a/inventory/middleware.py b/inventory/middleware.py index 7366f692..b321c5a6 100644 --- a/inventory/middleware.py +++ b/inventory/middleware.py @@ -4,6 +4,8 @@ from django.utils import timezone from django.shortcuts import redirect from django.urls import reverse +from inventory.utils import get_user_type + logger = logging.getLogger('user_activity') @@ -37,7 +39,8 @@ class InjectParamsMiddleware: def __call__(self, request): try: - request.entity = request.user.dealer.entity + # request.entity = request.user.dealer.entity + request.dealer = get_user_type(request) except Exception as e: pass diff --git a/inventory/migrations/0002_alter_lead_customer.py b/inventory/migrations/0002_alter_lead_customer.py new file mode 100644 index 00000000..03bc8b02 --- /dev/null +++ b/inventory/migrations/0002_alter_lead_customer.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.17 on 2025-02-04 09:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.DJANGO_LEDGER_CUSTOMER_MODEL), + ('inventory', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='lead', + name='customer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leads', to=settings.DJANGO_LEDGER_CUSTOMER_MODEL), + ), + ] diff --git a/inventory/migrations/0003_lead_email_lead_first_name_lead_last_name_and_more.py b/inventory/migrations/0003_lead_email_lead_first_name_lead_last_name_and_more.py new file mode 100644 index 00000000..b0548b08 --- /dev/null +++ b/inventory/migrations/0003_lead_email_lead_first_name_lead_last_name_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.17 on 2025-02-04 11:38 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.DJANGO_LEDGER_CUSTOMER_MODEL), + ('inventory', '0002_alter_lead_customer'), + ] + + operations = [ + migrations.AddField( + model_name='lead', + name='email', + field=models.EmailField(default='x@tenhal.sa', max_length=254, verbose_name='Email'), + preserve_default=False, + ), + migrations.AddField( + model_name='lead', + name='first_name', + field=models.CharField(default='test', max_length=50, verbose_name='First Name'), + preserve_default=False, + ), + migrations.AddField( + model_name='lead', + name='last_name', + field=models.CharField(default='test', max_length=50, verbose_name='Last Name'), + preserve_default=False, + ), + migrations.AddField( + model_name='lead', + name='phone_number', + field=phonenumber_field.modelfields.PhoneNumberField(default='056523656', max_length=128, region='SA', verbose_name='Phone Number'), + preserve_default=False, + ), + migrations.AlterField( + model_name='lead', + name='customer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='leads', to=settings.DJANGO_LEDGER_CUSTOMER_MODEL), + ), + ] diff --git a/inventory/migrations/0004_remove_lead_city_lead_address.py b/inventory/migrations/0004_remove_lead_city_lead_address.py new file mode 100644 index 00000000..47e4a975 --- /dev/null +++ b/inventory/migrations/0004_remove_lead_city_lead_address.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.17 on 2025-02-04 14:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0003_lead_email_lead_first_name_lead_last_name_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='lead', + name='city', + ), + migrations.AddField( + model_name='lead', + name='address', + field=models.CharField(default='', max_length=50, verbose_name='address'), + preserve_default=False, + ), + ] diff --git a/inventory/migrations/0005_schedule.py b/inventory/migrations/0005_schedule.py new file mode 100644 index 00000000..8701e470 --- /dev/null +++ b/inventory/migrations/0005_schedule.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.17 on 2025-02-04 15:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.DJANGO_LEDGER_CUSTOMER_MODEL), + ('inventory', '0004_remove_lead_city_lead_address'), + ] + + operations = [ + migrations.CreateModel( + name='Schedule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('purpose', models.CharField(max_length=200)), + ('scheduled_at', models.DateTimeField()), + ('notes', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to=settings.DJANGO_LEDGER_CUSTOMER_MODEL)), + ('lead', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to='inventory.lead')), + ('scheduled_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.staff')), + ], + options={ + 'ordering': ['-scheduled_at'], + }, + ), + ] diff --git a/inventory/migrations/0006_alter_schedule_purpose.py b/inventory/migrations/0006_alter_schedule_purpose.py new file mode 100644 index 00000000..2b834016 --- /dev/null +++ b/inventory/migrations/0006_alter_schedule_purpose.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2025-02-04 15:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0005_schedule'), + ] + + operations = [ + migrations.AlterField( + model_name='schedule', + name='purpose', + field=models.CharField(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')], max_length=200), + ), + ] diff --git a/inventory/migrations/0007_schedule_scheduled_type.py b/inventory/migrations/0007_schedule_scheduled_type.py new file mode 100644 index 00000000..ceaf14f9 --- /dev/null +++ b/inventory/migrations/0007_schedule_scheduled_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2025-02-04 15:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0006_alter_schedule_purpose'), + ] + + operations = [ + migrations.AddField( + model_name='schedule', + name='scheduled_type', + field=models.CharField(choices=[('Call', 'Call'), ('Meeting', 'Meeting'), ('Email', 'Email')], default='Call', max_length=200), + ), + ] diff --git a/inventory/models.py b/inventory/models.py index 4040c66e..5e372430 100644 --- a/inventory/models.py +++ b/inventory/models.py @@ -1,3 +1,4 @@ +from datetime import timezone import itertools from uuid import uuid4 from django.conf import settings @@ -1087,8 +1088,13 @@ class Representative(models.Model, LocalizedNameMixin): class Lead(models.Model): dealer = models.ForeignKey(Dealer, on_delete=models.CASCADE, related_name="leads") + first_name = models.CharField(max_length=50, verbose_name=_("First Name")) + 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")) customer = models.ForeignKey( - Customer, on_delete=models.CASCADE, related_name="leads" + CustomerModel, on_delete=models.CASCADE, related_name="leads", + null=True,blank=True ) id_car_make = models.ForeignKey( CarMake, @@ -1113,7 +1119,7 @@ class Lead(models.Model): channel = models.CharField( max_length=50, choices=Channel.choices, verbose_name=_("Channel") ) - city = models.CharField(max_length=50, verbose_name=_("City")) + address = models.CharField(max_length=50, verbose_name=_("address")) staff = models.ForeignKey( Staff, on_delete=models.SET_NULL, @@ -1146,7 +1152,80 @@ class Lead(models.Model): def __str__(self): return f"{self.first_name} {self.last_name}" + + @property + def is_converted(self): + return bool(self.customer) + + def to_dict(self): + return { + "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), + "id_car_make": str(self.id_car_make.name), + "id_car_model": str(self.id_car_model.name), + "year": str(self.year), + "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,entity): + if entity and not CustomerModel.objects.filter(email=self.email).exists(): + customer = entity.create_customer( + customer_model_kwargs={ + "customer_name": self.full_name, + "address_1": self.address, + "phone": self.phone_number, + "email": self.email, + } + ) + customer.additional_info = {} + customer.additional_info.update({"info":self.to_dict()}) + customer.save() + self.customer = customer + self.save() + + def get_latest_schedule(self): + return self.schedules.order_by('-scheduled_at').first() +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'), + ] + ScheduledType = [ + ('Call', 'Call'), + ('Meeting', 'Meeting'), + ('Email', 'Email'), + ] + lead = models.ForeignKey(Lead, on_delete=models.CASCADE, related_name='schedules') + customer = models.ForeignKey(CustomerModel, on_delete=models.CASCADE, related_name='schedules') + scheduled_by = models.ForeignKey(Staff, 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') + notes = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Scheduled {self.purpose} with {self.customer.customer_name} on {self.scheduled_at}" + + def schedule_past_date(self): + if self.scheduled_at < timezone.now(): + return True + return False + + class Meta: + ordering = ['-scheduled_at'] + class LeadStatusHistory(models.Model): lead = models.ForeignKey( @@ -1181,7 +1260,7 @@ class Opportunity(models.Model): Dealer, on_delete=models.CASCADE, related_name="opportunities" ) customer = models.ForeignKey( - Customer, on_delete=models.CASCADE, related_name="opportunities" + Customer, on_delete=models.CASCADE, related_name="opportunities" ) car = models.ForeignKey( Car, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Car") @@ -1213,7 +1292,7 @@ class Opportunity(models.Model): verbose_name_plural = _("Opportunities") def __str__(self): - return f"{self.car.id_car_make.name} - {self.car.id_car_model.name} : {self.customer.get_full_name}" + return f"{self.car.id_car_make.name} - {self.car.id_car_model.name} : {self.customer}" class Notes(models.Model): diff --git a/inventory/signals.py b/inventory/signals.py index 5192d437..678eed88 100644 --- a/inventory/signals.py +++ b/inventory/signals.py @@ -811,7 +811,7 @@ def notify_assigned_staff(sender, instance, created, **kwargs): if instance.staff: # Check if the lead is assigned models.Notification.objects.create( user=instance.staff.user, - message=f"You have been assigned a new lead: {instance.customer.get_full_name}." + message=f"You have been assigned a new lead: {instance.full_name}." ) diff --git a/inventory/urls.py b/inventory/urls.py index d1590793..f1352174 100644 --- a/inventory/urls.py +++ b/inventory/urls.py @@ -92,15 +92,25 @@ urlpatterns = [ path( "crm/leads//update/", views.LeadUpdateView.as_view(), name="lead_update" ), - path( - "crm/leads//delete/", views.LeadDeleteView.as_view(), name="lead_delete" - ), + path("crm/leads//delete/", views.LeadDeleteView, name="lead_delete"), path("crm/leads//add-note/", views.add_note_to_lead, name="add_note"), + path("crm/leads//lead-convert/", views.lead_convert, name="lead_convert"), path( "crm/leads//add-activity/", views.add_activity_to_lead, name="add_activity", ), + path( + "crm/leads//send_lead_email/", + views.send_lead_email, + name="send_lead_email", + ), + path( + "crm/leads//schedule/", + views.schedule_lead, + name="schedule_lead", + ), + path( "crm/opportunities/create/", views.OpportunityCreateView.as_view(), diff --git a/inventory/views.py b/inventory/views.py index 3c369910..1e94bae2 100644 --- a/inventory/views.py +++ b/inventory/views.py @@ -1779,7 +1779,7 @@ class OrganizationListView(LoginRequiredMixin, ListView): class OrganizationDetailView(DetailView): - model = models.Organization + model = CustomerModel template_name = "organizations/organization_detail.html" context_object_name = "organization" @@ -1787,12 +1787,8 @@ class OrganizationDetailView(DetailView): def OrganizationCreateView(request): if request.method == "POST": form = forms.OrganizationForm(request.POST) - # upload logo - image = request.FILES.get("logo") - file_name = default_storage.save("images/{}".format(image.name), image) - file_url = default_storage.url(file_name) - + organization_dict = { x: request.POST[x] for x in request.POST if x != "csrfmiddlewaretoken" } @@ -1806,7 +1802,12 @@ def OrganizationCreateView(request): "email": organization_dict["email"], } ) - organization_dict["logo"] = file_url + image = request.FILES.get("logo") + if image: + file_name = default_storage.save("images/{}".format(image.name), image) + file_url = default_storage.url(file_name) + + organization_dict["logo"] = file_url organization_dict["pk"] = str(instance.pk) instance.additional_info["organization_info"] = organization_dict instance.additional_info["type"] = "organization" @@ -2926,11 +2927,10 @@ class LeadCreateView(CreateView, SuccessMessageMixin, LoginRequiredMixin): model = models.Lead form_class = forms.LeadForm template_name = "crm/leads/lead_form.html" - # success_message = "Lead created successfully!" + success_message = "Lead created successfully!" success_url = reverse_lazy("lead_list") - def form_valid(self, form): - print("Form data:", form.cleaned_data) # Debug form data + def form_valid(self, form): dealer = get_user_type(self.request) form.instance.dealer = dealer return super().form_valid(form) @@ -2953,10 +2953,11 @@ class LeadUpdateView(UpdateView): success_url = reverse_lazy("lead_list") -class LeadDeleteView(DeleteView): - model = models.Lead - template_name = "crm/leads/lead_confirm_delete.html" - success_url = reverse_lazy("lead_list") +def LeadDeleteView(request,pk): + lead = get_object_or_404(models.Lead, pk=pk) + lead.delete() + messages.success(request, "Lead deleted successfully!") + return redirect("lead_list") def add_note_to_lead(request, pk): @@ -2974,6 +2975,74 @@ def add_note_to_lead(request, pk): form = forms.NoteForm() return render(request, "crm/add_note.html", {"form": form, "lead": lead}) +def lead_convert(request, pk): + lead = get_object_or_404(models.Lead, pk=pk) + dealer = get_user_type(request) + lead.convert_to_customer(dealer.entity) + messages.success(request, "Lead converted to customer successfully!") + return redirect("lead_list") + +def schedule_lead(request, pk): + lead = get_object_or_404(models.Lead, pk=pk) + if request.method == "POST": + form = forms.ScheduleForm(request.POST) + if form.is_valid(): + instance = form.save(commit=False) + instance.lead = lead + instance.customer = lead.customer + if hasattr(request.user, "staff"): + instance.scheduled_by = request.user.staff + instance.save() + messages.success(request, "Lead scheduled successfully!") + return redirect("lead_list") + else: + messages.error(request, f"Invalid form data: {str(form.errors)}") + return redirect("lead_list") + + form = forms.ScheduleForm() + return render(request, "crm/leads/schedule_lead.html", {"lead": lead, "form": form}) + +def send_lead_email(request, pk): + lead = get_object_or_404(models.Lead, pk=pk) + dealer = get_user_type(request) + lead.convert_to_customer(dealer.entity) + + if request.method == "POST": + send_email( + "manager@tenhal.com", + request.POST.get("to"), + request.POST.get("subject"), + request.POST.get("message"), + ) + messages.success(request, "Email sent successfully!") + return redirect("lead_list") + msg = f""" + السلام عليكم + Dear {lead.full_name}, + + أود أن أشارككم تقدير المشروع الذي ناقشناه. يرجى العثور على الوثيقة التفصيلية للمقترح المرفقة. + + I hope this email finds you well. I wanted to share with you the estimate for the project we discussed. Please find the detailed estimate document attached. + + يرجى مراجعة المقترح وإعلامي إذا كانت لديك أي أسئلة أو مخاوف. إذا كانت كل شيء يبدو جيدًا، يمكننا المضي قدمًا في المشروع. + + Please review the estimate and let me know if you have any questions or concerns. If everything looks good, we can proceed with the project. + + شكراً لاهتمامكم بهذا الأمر. + Thank you for your attention to this matter. + + تحياتي, + Best regards, + [Your Name] + [Your Position] + [Your Company] + [Your Contact Information] + """ + return render( + request, + "crm/leads/lead_send.html", + {"lead": lead, "message": msg}, + ) def add_activity_to_lead(request, pk): lead = get_object_or_404(models.Lead, pk=pk) @@ -3219,7 +3288,7 @@ class BillDetailView(LoginRequiredMixin, DetailView): transactions = [ { "item": x, - "total": Decimal(x.unit_cost) * Decimal(x.quantity), + "total": Decimal(x.unit_cost) * Decimal(x.quantity), } for x in txs ] @@ -3454,9 +3523,7 @@ def bill_create(request): ), } ) - car_list = models.Car.objects.filter( - dealer=dealer, finances__selling_price__gt=0, status="available" - ) + car_list = models.Car.objects.filter(dealer=dealer) context = { "form": form, "items": [ diff --git a/templates/base.html b/templates/base.html index 3346df96..ab69f9ba 100644 --- a/templates/base.html +++ b/templates/base.html @@ -52,7 +52,6 @@ - {% block customCSS %} @@ -135,10 +134,8 @@ function notify(tag,msg){ -