diff --git a/inventory/migrations/0003_alter_opportunity_customer.py b/inventory/migrations/0003_alter_opportunity_customer.py new file mode 100644 index 00000000..0e399ba7 --- /dev/null +++ b/inventory/migrations/0003_alter_opportunity_customer.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.7 on 2025-05-20 12:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0002_vendor_active'), + ] + + operations = [ + migrations.AlterField( + model_name='opportunity', + name='customer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='opportunities', to='inventory.customer'), + ), + ] diff --git a/inventory/migrations/0004_opportunity_slug.py b/inventory/migrations/0004_opportunity_slug.py new file mode 100644 index 00000000..69b4d398 --- /dev/null +++ b/inventory/migrations/0004_opportunity_slug.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-05-20 12:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0003_alter_opportunity_customer'), + ] + + operations = [ + migrations.AddField( + model_name='opportunity', + name='slug', + field=models.SlugField(blank=True, help_text='Unique slug for the opportunity.', null=True, unique=True, verbose_name='Slug'), + ), + ] diff --git a/inventory/migrations/0005_notes_dealer.py b/inventory/migrations/0005_notes_dealer.py new file mode 100644 index 00000000..63b30675 --- /dev/null +++ b/inventory/migrations/0005_notes_dealer.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.7 on 2025-05-20 13:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0004_opportunity_slug'), + ] + + operations = [ + migrations.AddField( + model_name='notes', + name='dealer', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='inventory.dealer'), + preserve_default=False, + ), + ] diff --git a/inventory/models.py b/inventory/models.py index 7d383d13..9ea526ad 100644 --- a/inventory/models.py +++ b/inventory/models.py @@ -1447,7 +1447,8 @@ class Lead(models.Model): def full_name(self): return f"{self.first_name} {self.last_name}" def convert_to_customer(self): - self.status = Status.QUALIFIED + self.status = Status.NEGOTIATION + self.is_converted = True self.save() return self.get_customer_model() def get_status(self): @@ -1587,7 +1588,7 @@ class Opportunity(models.Model): Dealer, on_delete=models.CASCADE, related_name="opportunities" ) customer = models.ForeignKey( - CustomerModel, on_delete=models.CASCADE, related_name="opportunities",null=True,blank=True + Customer, on_delete=models.CASCADE, related_name="opportunities",null=True,blank=True ) car = models.ForeignKey( Car, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Car") @@ -1611,15 +1612,22 @@ class Opportunity(models.Model): 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.")) + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(f"opportunity {self.customer.first_name} {self.customer.last_name}") + super(Opportunity, self).save(*args, **kwargs) class Meta: verbose_name = _("Opportunity") verbose_name_plural = _("Opportunities") def __str__(self): - return f"Opportunity for {self.customer.customer_name}" + return f"Opportunity for {self.customer.first_name} {self.customer.last_name}" class Notes(models.Model): + dealer = models.ForeignKey(Dealer, on_delete=models.CASCADE, related_name="notes") content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.UUIDField() content_object = GenericForeignKey("content_type", "object_id") diff --git a/inventory/urls.py b/inventory/urls.py index b733c0e0..00aaf1f3 100644 --- a/inventory/urls.py +++ b/inventory/urls.py @@ -83,6 +83,11 @@ urlpatterns = [ views.CustomerDetailView.as_view(), name="customer_detail", ), + path( + "customers//add-note/", + views.add_note_to_customer, + name="add_note_to_customer", + ), path( "customers//update/", views.CustomerUpdateView.as_view(), @@ -94,11 +99,6 @@ urlpatterns = [ views.OpportunityCreateView.as_view(), name="create_opportunity", ), - path( - "customers//add-note/", - views.add_note_to_customer, - name="add_note_to_customer", - ), path("crm/leads/create/", views.lead_create, name="lead_create"), path( "crm/leads//view/", views.LeadDetailView.as_view(), name="lead_detail" @@ -109,13 +109,21 @@ urlpatterns = [ path("crm/leads/", views.LeadListView.as_view(), name="lead_list"), path( - "crm/leads//update/", views.LeadUpdateView.as_view(), name="lead_update" + "crm/leads//update/", views.LeadUpdateView.as_view(), name="lead_update" ), path("crm/leads//delete/", views.LeadDeleteView, name="lead_delete"), - path("crm/leads//lead-convert/", views.lead_convert, name="lead_convert"), - path("crm/leads//add-note/", views.add_note_to_lead, name="add_note_to_lead"), - path('crm/leads//update-note/', views.update_note, name='update_note_to_lead'), + path("crm/leads//lead-convert/", views.lead_convert, name="lead_convert"), path("crm/leads//delete-note/", views.delete_note, name="delete_note_to_lead"), + path( + "crm//update-note/", + views.update_note, + name="update_note", + ), + path( + "crm///add-note/", + views.add_note, + name="add_note", + ), path( "crm//update-task/", views.update_task, @@ -152,13 +160,12 @@ urlpatterns = [ name="schedule_cancel", ), path( - "crm/leads//transfer/", + "crm/leads//transfer/", views.lead_transfer, name="lead_transfer", ), - path( - "crm/opportunities//add_note/", + "crm/opportunities//add_note/", views.add_note_to_opportunity, name="add_note_to_opportunity", ), @@ -168,17 +175,22 @@ urlpatterns = [ name="opportunity_create", ), path( - "crm/opportunities//create/", + "crm/opportunities//create/", + views.OpportunityCreateView.as_view(), + name="lead_opportunity_create", + ), + path( + "crm/opportunities//create/", views.OpportunityCreateView.as_view(), name="opportunity_create", ), path( - "crm/opportunities//", + "crm/opportunities//", views.OpportunityDetailView.as_view(), name="opportunity_detail", ), path( - "crm/opportunities//edit/", + "crm/opportunities//edit/", views.OpportunityUpdateView.as_view(), name="update_opportunity", ), @@ -193,7 +205,7 @@ urlpatterns = [ name="delete_opportunity", ), path( - "crm/opportunities//opportunity_update_status/", + "crm/opportunities//opportunity_update_status/", views.opportunity_update_status, name="opportunity_update_status", ), diff --git a/inventory/views.py b/inventory/views.py index bb224a3b..d3868453 100644 --- a/inventory/views.py +++ b/inventory/views.py @@ -22,7 +22,7 @@ from django.http.response import StreamingHttpResponse # Django from django.db.models import Q from django.conf import settings -from django.db import transaction +from django.db import IntegrityError, transaction from django.db.models import Func from django.contrib import messages from django.http import Http404, JsonResponse, HttpResponseForbidden @@ -1953,7 +1953,7 @@ class CustomerDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView @login_required -def add_note_to_customer(request, pk): +def add_note_to_customer(request, slug): """ This function allows authenticated users to add a note to a specific customer. The note creation is handled by a form, which is validated after submission. If the form @@ -1970,7 +1970,7 @@ def add_note_to_customer(request, pk): POST request, it renders the note form template with context including the form and customer. """ - customer = get_object_or_404(models.Customer, pk=pk) + customer = get_object_or_404(models.Customer, slug=slug) if request.method == "POST": form = forms.NoteForm(request.POST) if form.is_valid(): @@ -1979,7 +1979,7 @@ def add_note_to_customer(request, pk): note.created_by = request.user note.save() - return redirect("customer_detail", pk=customer.pk) + return redirect("customer_detail", slug=customer.slug) else: form = forms.NoteForm() return render( @@ -2206,9 +2206,16 @@ class VendorCreateView( success_message = _("Vendor created successfully") def form_valid(self, form): + if vendor:= models.Vendor.objects.filter(email=form.instance.email).first(): + if not vendor.active: + messages.error(self.request, _("Vendor Account with this email is Deactivated,Please Contact Admin")) + else: + messages.error(self.request, _("Vendor with this email already exists")) + return redirect("vendor_create") dealer = get_user_type(self.request) form.instance.dealer = dealer form.instance.save() + return super().form_valid(form) @@ -4597,6 +4604,7 @@ class LeadDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): context["transfer_form"] = forms.LeadTransferForm() context["activity_form"] = forms.ActivityForm() context["staff_task_form"] = forms.StaffTaskForm() + context["note_form"] = forms.NoteForm() return context @@ -4798,7 +4806,7 @@ def LeadDeleteView(request, slug): @login_required -def add_note_to_lead(request, pk): +def add_note_to_lead(request, slug): """ Adds a note to a specific lead. This view is accessible only to authenticated users. The function handles the POST request to create a new note associated @@ -4814,7 +4822,7 @@ def add_note_to_lead(request, pk): note creation or renders the note form template for GET or invalid POST requests. :rtype: HttpResponse """ - lead = get_object_or_404(models.Lead, pk=pk) + lead = get_object_or_404(models.Lead, slug=slug) if request.method == "POST": form = forms.NoteForm(request.POST) if form.is_valid(): @@ -4830,7 +4838,7 @@ def add_note_to_lead(request, pk): @login_required -def add_note_to_opportunity(request, pk): +def add_note_to_opportunity(request, slug): """ Add a note to a specific opportunity identified by its primary key. @@ -4844,7 +4852,7 @@ def add_note_to_opportunity(request, pk): :type pk: int :return: A redirect response to the detailed view of the opportunity. """ - opportunity = get_object_or_404(models.Opportunity, pk=pk) + opportunity = get_object_or_404(models.Opportunity, slug=slug) if request.method == "POST": notes = request.POST.get("notes") if not notes: @@ -4852,7 +4860,7 @@ def add_note_to_opportunity(request, pk): else: models.Notes.objects.create(content_object=opportunity, created_by=request.user,note=notes) messages.success(request, _("Note added successfully")) - return redirect("opportunity_detail", pk=opportunity.pk) + return redirect("opportunity_detail", slug=opportunity.slug) @login_required @@ -4910,14 +4918,15 @@ def delete_note(request, pk): """ note = get_object_or_404(models.Notes, pk=pk, created_by=request.user) lead_pk = note.content_object.pk + lead = models.Lead.objects.get(pk=lead_pk) note.delete() messages.success(request, _("Note deleted successfully.")) - return redirect("lead_detail", pk=lead_pk) + return redirect("lead_detail", slug=lead.slug) @login_required @permission_required("inventory.change_lead", raise_exception=True) -def lead_convert(request, pk): +def lead_convert(request, slug): """ Converts a lead into a customer and creates a corresponding opportunity. @@ -4934,20 +4943,20 @@ def lead_convert(request, pk): :return: An HTTP response redirecting to the lead list view. :rtype: HttpResponse """ - lead = get_object_or_404(models.Lead, pk=pk) + lead = get_object_or_404(models.Lead, slug=slug) dealer = get_user_type(request) if hasattr(lead, "opportunity"): messages.error(request, _("Lead is already converted to customer")) else: customer = lead.convert_to_customer() - models.Opportunity.objects.create(dealer=dealer,customer=customer,lead=lead,probability=50,stage=models.Stage.PROSPECT,staff=lead.staff,status=models.Status.QUALIFIED) + models.Opportunity.objects.create(dealer=dealer,customer=customer,lead=lead,probability=50,stage=models.Stage.NEGOTIATION,staff=lead.staff) messages.success(request, _("Lead converted to customer successfully")) return redirect("lead_list") @login_required @permission_required("inventory.add_lead", raise_exception=True) -def schedule_lead(request, pk): +def schedule_lead(request, slug): """ Handles the scheduling of a lead for an appointment. @@ -4970,7 +4979,7 @@ def schedule_lead(request, pk): messages.error(request, _("You do not have permission to schedule lead")) return redirect("lead_list") dealer = get_user_type(request) - lead = get_object_or_404(models.Lead, pk=pk, dealer=dealer) + lead = get_object_or_404(models.Lead, slug=slug, dealer=dealer) if request.method == "POST": form = forms.ScheduleForm(request.POST) if form.is_valid(): @@ -4998,7 +5007,7 @@ def schedule_lead(request, pk): ) except ValidationError as e: messages.error(request, str(e)) - return redirect("schedule_lead", pk=lead.pk) + return redirect("schedule_lead", slug=lead.slug) client = get_object_or_404(User, email=lead.email) # Create Appointment @@ -5022,7 +5031,7 @@ def schedule_lead(request, pk): @login_required @permission_required("inventory.change_lead", raise_exception=True) -def lead_transfer(request, pk): +def lead_transfer(request, slug): """ Handles the transfer of a lead to a different staff member. This view is accessible only to authenticated users with the 'inventory.change_lead' permission. If the @@ -5034,7 +5043,7 @@ def lead_transfer(request, pk): :param pk: The primary key of the lead to be transferred. :return: An HTTP redirect response to the lead list view. """ - lead = get_object_or_404(models.Lead, pk=pk) + lead = get_object_or_404(models.Lead, slug=slug) if request.method == "POST": form = forms.LeadTransferForm(request.POST) if form.is_valid(): @@ -5194,24 +5203,14 @@ class OpportunityCreateView(CreateView,SuccessMessageMixin, LoginRequiredMixin): template_name = "crm/opportunities/opportunity_form.html" success_message = "Opportunity created successfully." - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) + def get_initial(self): + initial = super().get_initial() dealer = get_user_type(self.request) - return context - - # def get_form_kwargs(self): - # kwargs = super().get_form_kwargs() - # dealer = get_user_type(self.request) - # kwargs["car"].queryset = models.Car.objects.filter(dealer=dealer,) - # return kwargs - - # def get_initial(self): - # initial = super().get_initial() - # if self.kwargs.get("pk", None): - # lead = models.Lead.objects.get(pk=self.kwargs.get("pk")) - - # initial["customer"] = lead.customer - # return initial + 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 + return initial def form_valid(self, form): dealer = get_user_type(self.request) @@ -5252,7 +5251,7 @@ class OpportunityUpdateView(LoginRequiredMixin,SuccessMessageMixin, UpdateView): success_message = "Opportunity updated successfully." def get_success_url(self): - return reverse_lazy("opportunity_detail", kwargs={"pk": self.object.pk}) + return reverse_lazy("opportunity_detail", kwargs={"slug": self.object.slug}) class OpportunityDetailView(LoginRequiredMixin, DetailView): @@ -5360,7 +5359,7 @@ def delete_opportunity(request, pk): @login_required -def opportunity_update_status(request, pk): +def opportunity_update_status(request, slug): """ Update the status and/or stage of a specific Opportunity instance. This is a view function, which is generally tied to a URL endpoint in a Django application. @@ -5385,7 +5384,7 @@ def opportunity_update_status(request, pk): frontend behavior. :rtype: HttpResponse """ - opportunity = get_object_or_404(models.Opportunity, pk=pk) + opportunity = get_object_or_404(models.Opportunity, slug=slug) status = request.GET.get("status") stage = request.GET.get("stage") if status: @@ -5394,7 +5393,7 @@ def opportunity_update_status(request, pk): opportunity.stage = stage opportunity.save() messages.success(request,_("Opportunity status updated successfully")) - response = HttpResponse(redirect("opportunity_detail",pk=opportunity.pk)) + response = HttpResponse(redirect("opportunity_detail",slug=opportunity.slug)) response['HX-Refresh'] = 'true' return response @@ -7784,6 +7783,7 @@ def add_activity(request,content_type,slug): else: messages.error(request, _("Activity form is not valid")) return redirect(f"{content_type}_detail", slug=slug) + def add_task(request,content_type,slug): try: model = apps.get_model(f'inventory.{content_type}') @@ -7824,4 +7824,43 @@ def update_task(request,pk): 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}) \ No newline at end of file + return render(request,'crm/leads/lead_detail.html',{'lead':lead,'tasks':tasks}) + +def add_note(request,content_type,slug): + try: + model = apps.get_model(f'inventory.{content_type}') + except LookupError: + raise Http404("Model not found") + + obj = get_object_or_404(model, slug=slug) + dealer = get_user_type(request) + if request.method == "POST": + form = forms.NoteForm(request.POST) + if form.is_valid(): + note = form.save(commit=False) + note.dealer = dealer + note.content_object = obj + note.created_by = request.user + + note.save() + messages.success(request, _("Note added successfully")) + else: + print(form.errors) + messages.error(request, _("Note form is not valid")) + return redirect(f"{content_type}_detail", slug=slug) + +def update_note(request,pk): + note = get_object_or_404(models.Notes, pk=pk) + lead = get_object_or_404(models.Lead, pk=note.content_object.id) + dealer = get_user_type(request) + if request.method == "POST": + note.note = request.POST.get('note') + note.save() + messages.success(request, _("Note updated successfully")) + return redirect(f"lead_detail", slug=lead.slug) + else: + messages.error(request, _("Note form is not valid")) + notes = models.Notes.objects.filter( + content_type__model="lead", object_id=lead.id,dealer=dealer + ) + return render(request,'crm/leads/lead_detail.html',{'lead':lead,'notes':notes}) \ No newline at end of file diff --git a/templates/crm/leads/lead_detail.html b/templates/crm/leads/lead_detail.html index 2317bf7f..49ee9ca1 100644 --- a/templates/crm/leads/lead_detail.html +++ b/templates/crm/leads/lead_detail.html @@ -230,7 +230,7 @@ -
+ {% comment %}

{{ _("Other Information")}}

@@ -101,7 +101,7 @@
-
+ {% endcomment %}
@@ -330,7 +330,7 @@

Notes

- + {% csrf_token %} @@ -366,7 +366,7 @@
@@ -394,7 +394,7 @@
@@ -553,5 +553,5 @@
- {% include "components/activity_modal.html" with content_type="opportunity" pk=opportunity.pk %} + {% include "components/activity_modal.html" with content_type="opportunity" slug=opportunity.slug %} {% endblock %} \ No newline at end of file diff --git a/templates/crm/opportunities/partials/opportunity_grid.html b/templates/crm/opportunities/partials/opportunity_grid.html index b5a38d74..ef6b3b8a 100644 --- a/templates/crm/opportunities/partials/opportunity_grid.html +++ b/templates/crm/opportunities/partials/opportunity_grid.html @@ -96,10 +96,10 @@ diff --git a/templates/customers/note_form.html b/templates/customers/note_form.html index 6e829d3c..633389ca 100644 --- a/templates/customers/note_form.html +++ b/templates/customers/note_form.html @@ -1,6 +1,6 @@ {% load i18n static crispy_forms_filters %} - + {% csrf_token %} {{ form|crispy }}