From 4f08cf757379fbb78f91e98dc33b2d52314bee0e Mon Sep 17 00:00:00 2001 From: nikhila05 Date: Tue, 18 Jun 2019 17:48:03 +0530 Subject: [PATCH] added tags field for campaign, changed styles for marketing module, added loading spinner and validation for file fields in campaign list create page, changes in marketing module (#238) * added models contacts, contactlists, campaigns for marketing module & completed crud operations for email templates model in marketing, added contact list crud operations, sample csv file added for reference, updated actions icons in all list pages, fixed issue of getting closed leads in accounts create/edit pages * completed crud operations to use email templates model, displaying contact list contacts and faile/invalid contacts, completed changes for new campaign, removed firstname and lastname as required and added title as required in leads create page, completed changes for Campaign email send with attachment * added test cases in marketing, common modules, updated documentation and changed jquery plugin in campaign * added mentions in comments section for all modules, added email templates when assigining a case or account, changed validation error messages in documents create and edit pages, added thumbnails for attachments in all detail pages * added tags field for campaign, changed styles for marketing module, added loading spinner and validation for file fields in campaign list create page, changes in marketing module --- .gitlab-ci.yml | 2 + Dockerfile | 2 +- README.rst | 22 + accounts/forms.py | 26 +- accounts/migrations/0007_email.py | 26 + .../migrations/0008_account_assigned_to.py | 20 + accounts/models.py | 23 + accounts/tasks.py | 28 + accounts/templates/accounts.html | 145 +- accounts/templates/create_account.html | 196 ++- accounts/templates/create_mail_accounts.html | 178 +++ accounts/templates/view_account.html | 276 +++- accounts/tests.py | 6 +- accounts/urls.py | 6 +- accounts/views.py | 115 +- cases/forms.py | 5 +- cases/tasks.py | 36 + cases/templates/view_case.html | 228 +-- cases/tests.py | 6 +- cases/urls.py | 2 +- cases/views.py | 101 +- celerybeat-schedule | Bin 20480 -> 0 bytes common/forms.py | 28 +- common/migrations/0013_auto_20190508_1540.py | 25 + common/migrations/0014_auto_20190524_1113.py | 25 + common/migrations/0015_auto_20190604_1830.py | 25 + common/models.py | 49 +- common/tasks.py | 129 ++ common/templates/comment_email.html | 18 + common/templates/doc_create.html | 2 + common/templates/list.html | 357 +++-- common/templates/settings/list.html | 4 +- common/templates/user_status.html | 14 + common/templates/user_status_in.html | 17 + common/tests.py | 28 +- common/urls.py | 6 +- common/views.py | 109 +- contacts/forms.py | 3 +- contacts/tasks.py | 34 + contacts/templates/contacts.html | 8 +- contacts/templates/view_contact.html | 629 ++++---- contacts/tests.py | 14 +- contacts/urls.py | 2 +- contacts/views.py | 111 +- crm/settings.py | 11 +- crm/urls.py | 5 +- events/__init__.py | 0 events/admin.py | 3 + events/apps.py | 5 + events/forms.py | 129 ++ events/migrations/0001_initial.py | 38 + .../migrations/0002_event_date_of_meeting.py | 18 + events/migrations/__init__.py | 0 events/models.py | 45 + events/tasks.py | 44 + .../assigned_to_email_template_event.html | 19 + events/templates/event_create.html | 251 ++++ events/templates/event_detail.html | 474 ++++++ events/templates/events_list.html | 366 +++++ events/tests.py | 3 + events/urls.py | 21 + events/views.py | 356 +++++ invoices/__init__.py | 0 invoices/admin.py | 3 + invoices/apps.py | 5 + invoices/forms.py | 99 ++ invoices/migrations/0001_initial.py | 47 + .../migrations/0002_auto_20190524_1113.py | 18 + .../migrations/0003_auto_20190527_1620.py | 23 + .../migrations/0004_auto_20190603_1844.py | 18 + invoices/migrations/__init__.py | 0 invoices/models.py | 95 ++ invoices/tasks.py | 67 + .../templates/assigned_to_email_template.html | 18 + invoices/templates/invoice_cancelled.html | 20 + invoices/templates/invoice_detail_email.html | 17 + invoices/templates/invoice_download_pdf.html | 203 +++ invoices/templates/invoices_create.html | 277 ++++ invoices/templates/invoices_detail.html | 520 +++++++ invoices/templates/invoices_list.html | 369 +++++ invoices/tests.py | 3 + invoices/urls.py | 26 + invoices/views.py | 415 ++++++ leads/forms.py | 5 +- .../migrations/0009_lead_created_from_site.py | 18 + leads/models.py | 6 +- leads/tasks.py | 32 +- leads/templates/leads.html | 130 +- leads/templates/view_leads.html | 620 ++++---- leads/tests.py | 14 +- leads/urls.py | 2 +- leads/views.py | 176 ++- marketing/forms.py | 113 +- .../migrations/0007_auto_20190611_1226.py | 27 + .../migrations/0008_auto_20190612_1905.py | 45 + marketing/models.py | 47 +- marketing/tasks.py | 99 +- marketing/templates/contact_detail.html | 114 ++ .../templates/html_message_campaign.html | 14 + marketing/tests.py | 52 +- marketing/urls.py | 45 +- marketing/views.py | 394 +++-- opportunity/forms.py | 5 +- opportunity/tasks.py | 36 + opportunity/templates/opportunity.html | 272 ++-- opportunity/templates/view_opportunity.html | 194 ++- opportunity/tests.py | 8 +- opportunity/urls.py | 2 +- opportunity/views.py | 123 +- requirements.txt | 3 +- static/css/bootstrap-suggest.css | 27 + static/css/main.scss | 2 +- static/js/ajaxForm.js | 1277 +++++++++++++++++ static/js/bootstrap-suggest.js | 592 ++++++++ static/js/bootstrap-typeahead.js | 335 +++++ system_requirements.txt | 7 + tasks/__init__.py | 0 tasks/admin.py | 5 + tasks/apps.py | 5 + tasks/celery_tasks.py | 33 + tasks/forms.py | 77 + tasks/migrations/0001_initial.py | 35 + tasks/migrations/0002_task_created_by.py | 21 + tasks/migrations/__init__.py | 0 tasks/models.py | 45 + tasks/templates/task_create.html | 166 +++ tasks/templates/task_detail.html | 438 ++++++ tasks/templates/tasks_email_template.html | 18 + tasks/templates/tasks_tasks_list.html | 311 ++++ tasks/tests.py | 3 + tasks/urls.py | 21 + tasks/utils.py | 11 + tasks/views.py | 312 ++++ templates/500.html | 8 +- templates/assigned_to/cases_assigned.html | 24 +- templates/assigned_to/contact_assigned.html | 24 +- templates/assigned_to/leads_assigned.html | 24 +- .../assigned_to/opportunity_assigned.html | 24 +- templates/change_password.html | 12 +- templates/login.html | 6 +- templates/marketing/base.html | 102 +- templates/marketing/campaign/details.html | 400 +++--- templates/marketing/campaign/index.html | 170 ++- templates/marketing/campaign/new.html | 353 +++-- templates/marketing/dashboard.html | 215 ++- .../marketing/email_template/details.html | 21 +- templates/marketing/email_template/index.html | 187 ++- templates/marketing/email_template/new.html | 224 +-- templates/marketing/lists/all.html | 533 ++++--- templates/marketing/lists/detail.html | 105 +- templates/marketing/lists/edit_contact.html | 131 +- templates/marketing/lists/failed_detail.html | 11 +- templates/marketing/lists/index.html | 223 ++- templates/marketing/lists/new.html | 198 +-- .../registration/password_reset_complete.html | 13 + .../registration/password_reset_confirm.html | 29 +- .../registration/password_reset_done.html | 3 + templates/root_email_template.html | 105 ++ templates/sales/base.html | 3 + templates/sales/index.html | 89 +- 160 files changed, 14026 insertions(+), 2665 deletions(-) create mode 100644 accounts/migrations/0007_email.py create mode 100644 accounts/migrations/0008_account_assigned_to.py create mode 100644 accounts/tasks.py create mode 100644 accounts/templates/create_mail_accounts.html create mode 100644 cases/tasks.py delete mode 100644 celerybeat-schedule create mode 100644 common/migrations/0013_auto_20190508_1540.py create mode 100644 common/migrations/0014_auto_20190524_1113.py create mode 100644 common/migrations/0015_auto_20190604_1830.py create mode 100644 common/tasks.py create mode 100644 common/templates/comment_email.html create mode 100644 common/templates/user_status.html create mode 100644 common/templates/user_status_in.html create mode 100644 contacts/tasks.py create mode 100644 events/__init__.py create mode 100644 events/admin.py create mode 100644 events/apps.py create mode 100644 events/forms.py create mode 100644 events/migrations/0001_initial.py create mode 100644 events/migrations/0002_event_date_of_meeting.py create mode 100644 events/migrations/__init__.py create mode 100644 events/models.py create mode 100644 events/tasks.py create mode 100644 events/templates/assigned_to_email_template_event.html create mode 100644 events/templates/event_create.html create mode 100644 events/templates/event_detail.html create mode 100644 events/templates/events_list.html create mode 100644 events/tests.py create mode 100644 events/urls.py create mode 100644 events/views.py create mode 100644 invoices/__init__.py create mode 100644 invoices/admin.py create mode 100644 invoices/apps.py create mode 100644 invoices/forms.py create mode 100644 invoices/migrations/0001_initial.py create mode 100644 invoices/migrations/0002_auto_20190524_1113.py create mode 100644 invoices/migrations/0003_auto_20190527_1620.py create mode 100644 invoices/migrations/0004_auto_20190603_1844.py create mode 100644 invoices/migrations/__init__.py create mode 100644 invoices/models.py create mode 100644 invoices/tasks.py create mode 100644 invoices/templates/assigned_to_email_template.html create mode 100644 invoices/templates/invoice_cancelled.html create mode 100644 invoices/templates/invoice_detail_email.html create mode 100644 invoices/templates/invoice_download_pdf.html create mode 100644 invoices/templates/invoices_create.html create mode 100644 invoices/templates/invoices_detail.html create mode 100644 invoices/templates/invoices_list.html create mode 100644 invoices/tests.py create mode 100644 invoices/urls.py create mode 100644 invoices/views.py create mode 100644 leads/migrations/0009_lead_created_from_site.py create mode 100644 marketing/migrations/0007_auto_20190611_1226.py create mode 100644 marketing/migrations/0008_auto_20190612_1905.py create mode 100644 marketing/templates/contact_detail.html create mode 100644 marketing/templates/html_message_campaign.html create mode 100644 opportunity/tasks.py create mode 100644 static/css/bootstrap-suggest.css create mode 100644 static/js/ajaxForm.js create mode 100644 static/js/bootstrap-suggest.js create mode 100644 static/js/bootstrap-typeahead.js create mode 100644 system_requirements.txt create mode 100644 tasks/__init__.py create mode 100644 tasks/admin.py create mode 100644 tasks/apps.py create mode 100644 tasks/celery_tasks.py create mode 100644 tasks/forms.py create mode 100644 tasks/migrations/0001_initial.py create mode 100644 tasks/migrations/0002_task_created_by.py create mode 100644 tasks/migrations/__init__.py create mode 100644 tasks/models.py create mode 100644 tasks/templates/task_create.html create mode 100644 tasks/templates/task_detail.html create mode 100644 tasks/templates/tasks_email_template.html create mode 100644 tasks/templates/tasks_tasks_list.html create mode 100644 tasks/tests.py create mode 100644 tasks/urls.py create mode 100644 tasks/utils.py create mode 100644 tasks/views.py create mode 100644 templates/root_email_template.html diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3c49fe0a..74aaf2bd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,6 +20,7 @@ deploy_mp: - pip install -r /home/dj_crm/dj_crm/requirements.txt - python /home/dj_crm/dj_crm/manage.py migrate --noinput - sudo /etc/init.d/uwsgi restart + - sudo /usr/bin/supervisorctl restart all only: - master tags: @@ -35,6 +36,7 @@ deploy_live: - pip install -r /home/dj_crm/dj_crm/requirements.txt - python /home/dj_crm/dj_crm/manage.py migrate --noinput - sudo /etc/init.d/uwsgi restart + - sudo /usr/bin/supervisorctl restart all only: - master tags: diff --git a/Dockerfile b/Dockerfile index aa455553..1b897595 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY . /app/ RUN curl -sL https://deb.nodesource.com/setup_10.x | bash - && \ apt update && \ - apt install -y git ruby-dev nodejs postgresql-client && \ + apt install -y git ruby-dev nodejs postgresql-client redis-server wkhtmltopdf && \ apt clean && \ gem install compass sass && \ npm -g install less && \ diff --git a/README.rst b/README.rst index 488d40d6..dbea41a1 100644 --- a/README.rst +++ b/README.rst @@ -80,6 +80,28 @@ Demo credentials for Django CRM: * **Password:** admin +Installation +============ + +* Install the dependencies + + .. code-block:: python + + pip install -r requirements.txt + python manage.py makemigrations + python manage.py createsuperuser + python manage.py runserver + + This will install all the required dependencies for django-crm. + After this download and install the System Requirements. + + +System Requirements +=================== + +- wkhtmltopdf (https://wkhtmltopdf.org/downloads.html) +- sass (https://www.npmjs.com/package/sass) or (https://rubygems.org/gems/sass) + Community ========= diff --git a/accounts/forms.py b/accounts/forms.py index fc5d4359..2815fbf9 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -1,6 +1,6 @@ from django import forms from .models import Account -from common.models import Comment, Attachments +from common.models import Comment, Attachments, User from leads.models import Lead from contacts.models import Contact from django.db.models import Q @@ -36,14 +36,18 @@ class AccountForm(forms.ModelForm): 'placeholder': 'Postcode'}) self.fields["billing_country"].choices = [ ("", "--Country--"), ] + list(self.fields["billing_country"].choices)[1:] - self.fields["lead"].queryset = Lead.objects.all( - ).exclude(status='closed') - if request_user: + # self.fields["lead"].queryset = Lead.objects.all( + # ).exclude(status='closed') + if request_user.role == 'ADMIN': + self.fields["lead"].queryset = Lead.objects.filter().exclude(status='closed') + self.fields["contacts"].queryset = Contact.objects.filter() + else: self.fields["lead"].queryset = Lead.objects.filter( Q(assigned_to__in=[request_user]) | Q(created_by=request_user)).exclude(status='closed') self.fields["contacts"].queryset = Contact.objects.filter( Q(assigned_to__in=[request_user]) | Q(created_by=request_user)) + self.fields['assigned_to'].required = False if account_view: self.fields['billing_address_line'].required = True self.fields['billing_street'].required = True @@ -55,7 +59,7 @@ class AccountForm(forms.ModelForm): class Meta: model = Account fields = ('name', 'phone', 'email', 'website', 'industry', - 'description', 'status', + 'description', 'status', 'assigned_to', 'billing_address_line', 'billing_street', 'billing_city', 'billing_state', 'billing_postcode', 'billing_country', 'lead', 'contacts') @@ -75,3 +79,15 @@ class AccountAttachmentForm(forms.ModelForm): class Meta: model = Attachments fields = ('attachment', 'account') + + +class EmailForm(forms.Form): + recipients = forms.CharField(max_length=500) + message_subject = forms.CharField(max_length=500) + message_body = forms.CharField(widget=forms.Textarea) + + def __init__(self, *args, **kwargs): + super(EmailForm, self).__init__(*args, **kwargs) + self.fields['message_subject'].widget.attrs['class'] = 'form-control' + self.fields['message_subject'].widget.attrs['required'] = True + self.fields['message_subject'].widget.attrs['placeholder'] = 'Email Subject' diff --git a/accounts/migrations/0007_email.py b/accounts/migrations/0007_email.py new file mode 100644 index 00000000..26f28b3d --- /dev/null +++ b/accounts/migrations/0007_email.py @@ -0,0 +1,26 @@ +# Generated by Django 2.1.7 on 2019-04-30 08:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contacts', '0003_merge_20190214_1427'), + ('accounts', '0006_auto_20190212_1708'), + ] + + operations = [ + migrations.CreateModel( + name='Email', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sent_at', models.DateTimeField(auto_now_add=True)), + ('message_subject', models.TextField(null=True)), + ('message_body', models.TextField(null=True)), + ('recipient', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recieved_email', to='contacts.Contact')), + ('sender', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_email', to='accounts.Account')), + ], + ), + ] diff --git a/accounts/migrations/0008_account_assigned_to.py b/accounts/migrations/0008_account_assigned_to.py new file mode 100644 index 00000000..5ed47fcf --- /dev/null +++ b/accounts/migrations/0008_account_assigned_to.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.7 on 2019-05-17 09:14 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('accounts', '0007_email'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='assigned_to', + field=models.ManyToManyField(related_name='account_assigned_users', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index bf43f95f..4529fa09 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -6,6 +6,7 @@ from common.models import User from common.utils import INDCHOICES, COUNTRIES from phonenumber_field.modelfields import PhoneNumberField from django.utils.text import slugify +from contacts.models import Contact class Tags(models.Model): @@ -65,6 +66,8 @@ class Account(models.Model): "Name of Contact", "Contact Name"), max_length=120) contacts = models.ManyToManyField( 'contacts.Contact', related_name="account_contacts") + assigned_to = models.ManyToManyField( + User, related_name='account_assigned_users') def __str__(self): return self.name @@ -102,3 +105,23 @@ class Account(models.Model): else: address += self.get_billing_country_display() return address + + +class Email(models.Model): + sender = models.ForeignKey( + Account, related_name='sent_email', on_delete=models.SET_NULL, null=True) + + recipient = models.ForeignKey( + Contact, related_name='recieved_email', on_delete=models.SET_NULL, null=True) + + sent_at = models.DateTimeField(auto_now_add=True) + message_subject = models.TextField(null=True) + message_body = models.TextField(null=True) + + def __str__(self): + return self.message_body + + def save(self, *args, **kwargs): + # prevent user from sending messages to themselves + if self.sender.email != self.recipient.email: + super(Email, self).save(*args, **kwargs) diff --git a/accounts/tasks.py b/accounts/tasks.py new file mode 100644 index 00000000..faeac063 --- /dev/null +++ b/accounts/tasks.py @@ -0,0 +1,28 @@ +from celery.task import task +from django.conf import settings +from django.core.mail import EmailMessage +from django.template.loader import render_to_string +from accounts.models import Account, Email +from contacts.models import Contact + + +@task +def send_email(subject, html_content, from_email=None, + recipients=[]): + account = Account.objects.filter(id=from_email).first() + if account: + contacts = recipients + for contact in contacts: + contact_obj = Contact.objects.filter(email=contact).first() + if contact_obj: + Email.objects.create(sender=account, + recipient=contact_obj,message_subject=subject, + message_body=html_content) + msg = EmailMessage( + subject, + html_content, + from_email=account.email, + to=[contact_obj.email, ] + ) + msg.content_subtype = "html" + msg.send() \ No newline at end of file diff --git a/accounts/templates/accounts.html b/accounts/templates/accounts.html index ec1e69aa..0ca7b419 100644 --- a/accounts/templates/accounts.html +++ b/accounts/templates/accounts.html @@ -11,7 +11,8 @@
@@ -25,23 +26,27 @@
- +
- +
- + {% for tag in tags %} - + {% endfor%}
@@ -67,10 +72,12 @@
@@ -78,9 +85,10 @@
- Open Accounts - {% if show_pageitems %} {% show_pageitems %}{% else %}{{open_accounts|length}}{% endif %} + Open Accounts - {% if show_pageitems %} + {% show_pageitems %}{% else %}{{open_accounts|length}}{% endif %} - +
@@ -106,19 +114,25 @@ {% endif %} {% for each_account in open_accounts %} - {{ forloop.counter }} - {% if each_account.name %}{{ each_account.name }}{% endif %} + {{ forloop.counter }} + {% if each_account.name %}{{ each_account.name }}{% endif %} + {% if each_account.created_by.profile_pic %} - {% thumbnail each_account.created_by.profile_pic "40x40" crop="center" as im %} - - {% endthumbnail %} + {% thumbnail each_account.created_by.profile_pic "40x40" crop="center" as im %} + + {% endthumbnail %} {% else %} - Micro profile pic + Micro profile pic {% endif %} - {% if each_account.billing_city %}{{ each_account.billing_city }} {% else %} Not specified {% endif %} - {% if each_account.billing_state %}{{ each_account.billing_state }} {% else %} Not specified {% endif %} + {% if each_account.billing_city %}{{ each_account.billing_city }} {% else %} Not specified + {% endif %} + {% if each_account.billing_state %}{{ each_account.billing_state }} {% else %} Not + specified {% endif %} {% with tags=each_account.tags.all %} {% if tags %} @@ -132,8 +146,10 @@ {# View #} - - + + {% endfor %} @@ -153,10 +169,11 @@
- Closed Accounts - {% if show_pageitems %} {% show_pageitems %}{% else %}{{close_accounts|length}}{% endif %} + Closed Accounts - {% if show_pageitems %} + {% show_pageitems %}{% else %}{{close_accounts|length}}{% endif %} - - + +
@@ -179,11 +196,15 @@ {% paginate 10 close_accounts %} {% endif %} {% for each_account in close_accounts %} - + - - - + + + {% endfor %} @@ -228,48 +251,72 @@ {% block js_block %} @@ -196,11 +226,26 @@ // $(document).ready(function() { // $('.assigned_users').select2(); // }); - $(document).ready(function() { + $(document).ready(function () { $("#id_contacts").select2(); + $("#id_assigned_to").select2() + $("#id_assigned_to").change(function(){ + $("#id_assigned_to").attr('name', 'assigned_to') + }) + // $("#id_assigned_to") + // var data_assigned_to = [] + // data_assigned_to = $('#id_assigned_to').val() + // $('#id_assigned_to_').val($('#id_assigned_to').val()) + // console.log($('#id_assigned_to').val()) + // console.log($('#id_assigned_to_').val()) + // $('#id_assigned_to').change(function(){ + // data_assigned_to = [] + // data_assigned_to.push($(this).val()) + // $('#id_assigned_to_hidden').val(data_assigned_to) + // }) }); - $("#copy_billing_btn").click(function(){ + $("#copy_billing_btn").click(function () { var address_line = $("#id_address_line").val(); var street = $("#id_street").val(); var city = $("#id_city").val(); @@ -208,52 +253,50 @@ var postcode = $("#id_postcode").val(); var country = $("#id_country").val(); }); - - $(".savenew").on('click', function(e){ + + $(".savenew").on('click', function (e) { e.preventDefault() $("#save_new_form").val('true') $('#formid').submit() }); - + function onAddTag(tag) { alert("Added a tag: " + tag); } function onRemoveTag(tag) { alert("Removed a tag: " + tag); } - - function onChangeTag(input,tag) { + + function onChangeTag(input, tag) { alert("Changed a tag: " + tag); } - - $(function() { - - $('#tags_1').tagsInput({width:'auto'}); + + $(function () { + + $('#tags_1').tagsInput({ width: 'auto' }); $('#tags_2').tagsInput({ width: 'auto', - onChange: function(elem, elem_tags) - { - var languages = ['php','ruby','javascript']; - $('.tag', elem_tags).each(function() - { - if($(this).text().search(new RegExp('\\b(' + languages.join('|') + ')\\b')) >= 0) + onChange: function (elem, elem_tags) { + var languages = ['php', 'ruby', 'javascript']; + $('.tag', elem_tags).each(function () { + if ($(this).text().search(new RegExp('\\b(' + languages.join('|') + ')\\b')) >= 0) $(this).css('background-color', 'yellow'); }); } }); $('#tags_3').tagsInput({ width: 'auto', - + //autocomplete_url:'test/fake_plaintext_endpoint.html' //jquery.autocomplete (not jquery ui) - autocomplete_url:'test/fake_json_endpoint.html' // jquery ui autocomplete requires a json endpoint + autocomplete_url: 'test/fake_json_endpoint.html' // jquery ui autocomplete requires a json endpoint }); - - - // Uncomment this line to see the callback functions in action - // $('input.tags').tagsInput({onAddTag:onAddTag,onRemoveTag:onRemoveTag,onChange: onChangeTag}); - - // Uncomment this line to see an input with no interface for adding new tags. - // $('input.tags').tagsInput({interactive:false}); + + + // Uncomment this line to see the callback functions in action + // $('input.tags').tagsInput({onAddTag:onAddTag,onRemoveTag:onRemoveTag,onChange: onChangeTag}); + + // Uncomment this line to see an input with no interface for adding new tags. + // $('input.tags').tagsInput({interactive:false}); }); function remove_attachment(x) { const con = confirm("Do you want to Delete it for Sure!?"); @@ -273,22 +316,29 @@ $('form#formid').ajaxForm({ - type:'POST', - dataType:'json', + type: 'POST', + dataType: 'json', url: ".", - data:$('#formid').serialize(), - success: function(data) { - if(data.error){ + data: $('#formid').serialize(), + success: function (data) { + if (data.error) { $('.error').html('') for (var key in data.errors) { - $('#id__'+key).html("

" + data.errors[key][0] + "

"); + $('#id__' + key).html("

" + data.errors[key][0] + "

"); }; $('#submit_btn').removeAttr('disabled') } - else{ - window.location = data.success_url; - } + else { + window.location = data.success_url; + } } }); + + // $('#call_save').click(function (e) { + // e.preventDefault() + // console.log($('#formid').serialize()) + // console.log($('#formid').serializeArray()) + + // }) -{% endblock js_block %} +{% endblock js_block %} \ No newline at end of file diff --git a/accounts/templates/create_mail_accounts.html b/accounts/templates/create_mail_accounts.html new file mode 100644 index 00000000..0780b1f8 --- /dev/null +++ b/accounts/templates/create_mail_accounts.html @@ -0,0 +1,178 @@ +{% extends 'sales/base.html' %} +{% load staticfiles %} +{% load paginate %} +{% block extralinks %} +{% load thumbnail %} + +{% endblock %} +{% block content %} + +
+ +
+
+
+
+
+
+
+
+ Send Mail +
+ +
+
+
+ + + + +
+ +
+
+
+ + +
+ +
+
+
+ +
+
+ + +
+ +
+ + Cancel +
+
+ +
+
+
+
+ +
+
+
+{% endblock %} +{% block js_block %} + + + +{% endblock js_block %} \ No newline at end of file diff --git a/accounts/templates/view_account.html b/accounts/templates/view_account.html index 6c9d09b4..5178e4eb 100644 --- a/accounts/templates/view_account.html +++ b/accounts/templates/view_account.html @@ -1,10 +1,21 @@ {% extends 'sales/base.html' %} {% load staticfiles %} +{% load thumbnail %} {% block breadcrumb %} + +{% block extralinks %} + + +{% endblock extralinks %} + @@ -12,19 +23,19 @@ {% block content %}
-
-
+
+
Overview - + @@ -34,7 +45,7 @@
{% if account_record.name %}
-
+
@@ -74,12 +85,12 @@
{% endif %} - {% if account_record.contacts %} + {% if account_record.contacts.all %}
{% for contact in account_record.contacts.all %} - + {% endfor %}
@@ -104,11 +115,24 @@
{% endif %} +
+ {% if account_record.assigned_to.all %} +
+ +
+ {% for user in account_record.assigned_to.all %} +
{{ user }}
+ {% endfor %} +
+
+ {% endif %} +
{% if account_record.description %}
- +
{% endif %} @@ -127,7 +151,8 @@
- Created by {{ account_record.created_by }} created on {{ account_record.created_on }} + Created by {{ account_record.created_by }} created on + {{ account_record.created_on }}
@@ -141,10 +166,13 @@
-
+
+ +

- +
@@ -155,11 +183,19 @@ {% if request.user.is_superuser or request.user.role == 'ADMIN' or request.user == attachment.created_by %}
- +
{% endif %}
+ {% if attachment.attachment %} + {% if 'image' in attachment.file_type %} + {% thumbnail attachment.attachment "80x80" crop="center" as im %} + + {% endthumbnail %} + {% endif %} + {% endif %}
  {{attachment.file_name}} Download
@@ -183,11 +219,15 @@ {% if comment_permission %}
- -
+ +
+ +

- +
@@ -198,7 +238,8 @@
  • {% if request.user.is_superuser or request.user.role == 'ADMIN' or request.user == comment.commented_by %}
    - +
  • -
    {{ forloop.counter }}{% if each_account.name %}{{ each_account.name }}{% endif %}{% if each_account.billing_city %}{{ each_account.billing_city }} {% else %} Not specified {% endif %}{% if each_account.billing_state %}{{ each_account.billing_state }} {% else %} Not specified {% endif %}{% if each_account.name %}{{ each_account.name }}{% endif %} + {% if each_account.billing_city %}{{ each_account.billing_city }} {% else %} Not specified + {% endif %}{% if each_account.billing_state %}{{ each_account.billing_state }} {% else %} Not + specified {% endif %} {% with tags=each_account.tags.all %} {% if tags %} @@ -196,8 +217,10 @@ {% endwith %} - - + +
    @@ -280,9 +331,11 @@ {% endfor %} @@ -305,7 +358,8 @@
    Opportunities - +
    {{ contact.phone }} {{ contact.email }} - View - Edit - Remove + View + Edit + Remove
    @@ -327,10 +381,13 @@ - {% endfor %} @@ -353,7 +410,8 @@
    Cases - +
    {{opportunity.name}} {{opportunity.stage}} {{opportunity.probability}} {% if opportunity.probability %} % {% endif %} - View - Edit - Remove + + View + Edit + Remove
    @@ -375,10 +433,12 @@ - {% endfor %} @@ -394,38 +454,100 @@ + +
    +
    +
    +
    +
    + Tasks + +
    +
    +
    {{ case.name }} {{ case.status }} {{ case.priority }} - View - Edit - Remove + + View + Edit + Remove
    + {% if tasks|length > 0 %} + + + + + + + + + + {% endif %} + + {% for task in tasks %} + + + + + + + + {% endfor %} + +
    S.NoTitleStatusPriorityActions
    {{ forloop.counter }}{{task.title}}{{task.status}}{{task.priority}} {% if task.probability %} % {% endif %} + View + Edit + Remove +
    +
    + {% ifequal tasks|length 0 %} +
    No tasks Records Found
    + {% endifequal %} +
    +
    +
    +
    +
    +
    +
    + +
    + Processing File... +
    +
    {% endblock content %} {% block js_block %} + + + - {% endblock js_block %} \ No newline at end of file diff --git a/accounts/tests.py b/accounts/tests.py index ea3c4149..cfd3e53c 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -112,7 +112,7 @@ class AccountsListTestCase(AccountCreateTest, TestCase): def test_accounts_list(self): self.accounts = Account.objects.all() - response = self.client.get('/accounts/list/') + response = self.client.get(reverse('accounts:list')) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'accounts.html') @@ -125,7 +125,7 @@ class AccountsListTestCase(AccountCreateTest, TestCase): 'billing_state': "billing_state", 'billing_postcode': "billing_postcode", 'billing_country': "billing_country"} - response = self.client.post('/accounts/list/', data) + response = self.client.post(reverse('accounts:list'), data) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'accounts.html') @@ -152,7 +152,7 @@ class AccountsRemoveTestCase(AccountCreateTest, TestCase): def test_accounts_remove(self): response = self.client.get( '/accounts/' + str(self.account.id) + '/delete/') - self.assertEqual(response['location'], '/accounts/list/') + self.assertEqual(response['location'], '/accounts/') # def test_accounts_remove_status(self): # Account.objects.filter(id=self.account.id).delete() diff --git a/accounts/urls.py b/accounts/urls.py index b47c7050..d1b2f759 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -2,13 +2,13 @@ from django.urls import path from accounts.views import ( AccountsListView, CreateAccountView, AccountDetailView, AccountUpdateView, AccountDeleteView, AddCommentView, UpdateCommentView, DeleteCommentView, - AddAttachmentView, DeleteAttachmentsView + AddAttachmentView, DeleteAttachmentsView, create_mail ) app_name = 'accounts' urlpatterns = [ - path('list/', AccountsListView.as_view(), name='list'), + path('', AccountsListView.as_view(), name='list'), path('create/', CreateAccountView.as_view(), name='new_account'), path('/view/', AccountDetailView.as_view(), name="view_account"), path('/edit/', AccountUpdateView.as_view(), name="edit_account"), @@ -23,4 +23,6 @@ urlpatterns = [ name="add_attachment"), path('attachment/remove/', DeleteAttachmentsView.as_view(), name="remove_attachment"), + path('create-mail//', create_mail, + name="create_mail"), ] diff --git a/accounts/views.py b/accounts/views.py index 433fecb0..1c15e225 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,11 +1,11 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.http import JsonResponse -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404, redirect, render from django.views.generic import ( - CreateView, UpdateView, DetailView, TemplateView, View, DeleteView) + CreateView, UpdateView, DetailView, TemplateView, View, DeleteView, FormView) from accounts.forms import AccountForm, AccountCommentForm, \ - AccountAttachmentForm -from accounts.models import Account, Tags + AccountAttachmentForm, EmailForm +from accounts.models import Account, Tags, Email from common.models import User, Comment, Attachments from common.utils import INDCHOICES, COUNTRIES, \ CURRENCY_CODES, CASE_TYPE, PRIORITY_CHOICE, STATUS_CHOICE @@ -16,6 +16,10 @@ from django.urls import reverse_lazy, reverse from leads.models import Lead from django.core.exceptions import PermissionDenied from django.db.models import Q +from django.contrib.auth.decorators import login_required +from accounts.tasks import send_email +from common.tasks import send_email_user_mentions +from django.contrib.sites.shortcuts import get_current_site class AccountsListView(LoginRequiredMixin, TemplateView): @@ -28,6 +32,9 @@ class AccountsListView(LoginRequiredMixin, TemplateView): if self.request.user.role != "ADMIN" and not self.request.user.is_superuser: queryset = queryset.filter(created_by=self.request.user.id) + if self.request.GET.get('tag', None): + queryset = queryset.filter(tags__in = self.request.GET.getlist('tag')) + request_post = self.request.POST if request_post: if request_post.get('name'): @@ -40,9 +47,9 @@ class AccountsListView(LoginRequiredMixin, TemplateView): queryset = queryset.filter( industry__icontains=request_post.get('industry')) if request_post.get('tag'): - queryset = queryset.filter(tags__in=request_post.get('tag')) + queryset = queryset.filter(tags__in=request_post.getlist('tag')) - return queryset + return queryset.distinct() def get_context_data(self, **kwargs): context = super(AccountsListView, self).get_context_data(**kwargs) @@ -57,6 +64,12 @@ class AccountsListView(LoginRequiredMixin, TemplateView): context["per_page"] = self.request.POST.get('per_page') tag_ids = list(set(Account.objects.values_list('tags', flat=True))) context["tags"] = Tags.objects.filter(id__in=tag_ids) + if self.request.POST.get('tag', None): + context["request_tags"] = self.request.POST.getlist('tag') + elif self.request.GET.get('tag', None): + context["request_tags"] = self.request.GET.getlist('tag') + else: + context["request_tags"] = None search = False if ( @@ -88,15 +101,21 @@ class CreateAccountView(LoginRequiredMixin, CreateView): template_name = "create_account.html" def dispatch(self, request, *args, **kwargs): - self.users = User.objects.filter(is_active=True).order_by('email') + if self.request.user.role == 'ADMIN' or self.request.user.is_superuser: + self.users = User.objects.filter(is_active=True).order_by('email') + elif request.user.google.all(): + self.users = [] + else: + self.users = User.objects.filter(role='ADMIN').order_by('email') return super( CreateAccountView, self).dispatch(request, *args, **kwargs) def get_form_kwargs(self): kwargs = super(CreateAccountView, self).get_form_kwargs() kwargs.update({"account": True}) - if self.request.user.role != "ADMIN" and not self.request.user.is_superuser: - kwargs.update({"request_user": self.request.user}) + kwargs.update({"request_user": self.request.user}) + # if self.request.user.role != "ADMIN" and not self.request.user.is_superuser: + # kwargs.update({"request_user": self.request.user}) return kwargs def post(self, request, *args, **kwargs): @@ -125,6 +144,8 @@ class CreateAccountView(LoginRequiredMixin, CreateView): account_object.tags.add(tag) if self.request.POST.getlist('contacts', []): account_object.contacts.add(*self.request.POST.getlist('contacts')) + if self.request.POST.getlist('assigned_to', []): + account_object.assigned_to.add(*self.request.POST.getlist('assigned_to')) if self.request.FILES.get('account_attachment'): attachment = Attachments() attachment.created_by = self.request.user @@ -159,8 +180,13 @@ class CreateAccountView(LoginRequiredMixin, CreateView): context["industries"] = INDCHOICES context["countries"] = COUNTRIES context["contact_count"] = Contact.objects.count() - context["leads"] = Lead.objects.exclude( - status__in=['converted', 'closed']) + if self.request.user.role == 'ADMIN': + context["leads"] = Lead.objects.exclude( + status__in=['converted', 'closed']) + else: + context["leads"] = Lead.objects.filter( + Q(assigned_to__in=[self.request.user]) | Q(created_by=self.request.user)).exclude( + status__in=['converted', 'closed']) context["lead_count"] = context["leads"].count() if self.request.user.role != "ADMIN" and not self.request.user.is_superuser: context["lead_count"] = Lead.objects.filter( @@ -185,6 +211,13 @@ class AccountDetailView(LoginRequiredMixin, DetailView): self.request.user.is_superuser or self.request.user.role == 'ADMIN' ) else False + if self.request.user.is_superuser or self.request.user.role == 'ADMIN': + users_mention = list(User.objects.all().values('username')) + elif self.request.user != account_record.created_by: + users_mention = [{'username': account_record.created_by.username}] + else: + users_mention = [] + context.update({ "comments": account_record.accounts_comments.all(), "attachments": account_record.account_attachment.all(), @@ -201,6 +234,8 @@ class AccountDetailView(LoginRequiredMixin, DetailView): "case_priority": PRIORITY_CHOICE, "case_status": STATUS_CHOICE, 'comment_permission': comment_permission, + 'tasks':account_record.accounts_tasks.all(), + 'users_mention': users_mention, }) return context @@ -211,15 +246,21 @@ class AccountUpdateView(LoginRequiredMixin, UpdateView): template_name = "create_account.html" def dispatch(self, request, *args, **kwargs): - self.users = User.objects.filter(is_active=True).order_by('email') + if self.request.user.role == 'ADMIN' or self.request.user.is_superuser: + self.users = User.objects.filter(is_active=True).order_by('email') + elif request.user.google.all(): + self.users = [] + else: + self.users = User.objects.filter(role='ADMIN').order_by('email') return super(AccountUpdateView, self).dispatch( request, *args, **kwargs) def get_form_kwargs(self): kwargs = super(AccountUpdateView, self).get_form_kwargs() kwargs.update({"account": True}) - if self.request.user.role != "ADMIN" and not self.request.user.is_superuser: - kwargs.update({"request_user": self.request.user}) + kwargs.update({"request_user": self.request.user}) + # if self.request.user.role != "ADMIN" and not self.request.user.is_superuser: + # kwargs.update({"request_user": self.request.user}) return kwargs def post(self, request, *args, **kwargs): @@ -248,6 +289,11 @@ class AccountUpdateView(LoginRequiredMixin, UpdateView): if self.request.POST.getlist('contacts', []): account_object.contacts.clear() account_object.contacts.add(*self.request.POST.getlist('contacts')) + if self.request.POST.getlist('assigned_to', []): + account_object.assigned_to.clear() + account_object.assigned_to.add(*self.request.POST.getlist('assigned_to')) + else: + account_object.assigned_to.clear() if self.request.FILES.get('account_attachment'): attachment = Attachments() attachment.created_by = self.request.user @@ -282,8 +328,13 @@ class AccountUpdateView(LoginRequiredMixin, UpdateView): context["industries"] = INDCHOICES context["countries"] = COUNTRIES context["contact_count"] = Contact.objects.count() - context["leads"] = Lead.objects.exclude( - status__in=['converted', 'closed']) + if self.request.user.role == 'ADMIN': + context["leads"] = Lead.objects.exclude( + status__in=['converted', 'closed']) + else: + context["leads"] = Lead.objects.filter( + Q(assigned_to__in=[self.request.user]) | Q(created_by=self.request.user)).exclude( + status__in=['converted', 'closed']) context["lead_count"] = context["leads"].count() if self.request.user.role != "ADMIN" and not self.request.user.is_superuser: context["lead_count"] = Lead.objects.filter( @@ -331,6 +382,11 @@ class AddCommentView(LoginRequiredMixin, CreateView): comment.commented_by = self.request.user comment.account = self.account comment.save() + comment_id = comment.id + current_site = get_current_site(self.request) + send_email_user_mentions.delay(comment_id, 'accounts', domain=current_site.domain, + protocol=self.request.scheme) + return JsonResponse({ "comment_id": comment.id, "comment": comment.comment, "commented_on": comment.commented_on, @@ -360,6 +416,10 @@ class UpdateCommentView(LoginRequiredMixin, View): def form_valid(self, form): self.comment_obj.comment = form.cleaned_data.get("comment") self.comment_obj.save(update_fields=["comment"]) + comment_id = self.comment_obj.id + current_site = get_current_site(self.request) + send_email_user_mentions.delay(comment_id, 'accounts', domain=current_site.domain, + protocol=self.request.scheme) return JsonResponse({ "comment_id": self.comment_obj.id, "comment": self.comment_obj.comment, @@ -422,7 +482,8 @@ class AddAttachmentView(LoginRequiredMixin, CreateView): kwargs={'pk': attachment.id}), "attachment_display": attachment.get_file_type_display(), "created_on": attachment.created_on, - "created_by": attachment.created_by.email + "created_by": attachment.created_by.email, + "file_type": attachment.file_type() }) def form_invalid(self, form): @@ -446,3 +507,23 @@ class DeleteAttachmentsView(LoginRequiredMixin, View): data = { 'error': "You don't have permission to delete this attachment."} return JsonResponse(data) + +@login_required +def create_mail(request, account_id): + + if request.method == 'GET': + account = get_object_or_404(Account, pk=account_id) + contacts_list = list(account.contacts.all().values('email')) + email_form = EmailForm() + return render(request, 'create_mail_accounts.html', {'account_id': account_id, + 'contacts_list': contacts_list, 'email_form': email_form}) + + if request.method == 'POST': + form = EmailForm(request.POST) + if form.is_valid(): + send_email.delay(form.data.get('message_subject'), form.data.get('message_body'), + from_email=account_id, recipients=form.data.get('recipients').split(',')) + + return JsonResponse({'error': False}) + else: + return JsonResponse({'error': True, 'errors': form.errors}) diff --git a/cases/forms.py b/cases/forms.py index 076dcdb3..8c4861ff 100644 --- a/cases/forms.py +++ b/cases/forms.py @@ -14,10 +14,11 @@ class CaseForm(forms.ModelForm): field.widget.attrs = {"class": "form-control"} self.fields['description'].widget.attrs.update({ 'rows': '6'}) - self.fields['assigned_to'].queryset = assigned_users + if assigned_users: + self.fields['assigned_to'].queryset = assigned_users + self.fields['assigned_to'].required = False self.fields['account'].queryset = case_accounts self.fields['contacts'].queryset = case_contacts - self.fields['assigned_to'].required = False self.fields['contacts'].required = False for key, value in self.fields.items(): value.widget.attrs['placeholder'] = value.label diff --git a/cases/tasks.py b/cases/tasks.py new file mode 100644 index 00000000..df560af1 --- /dev/null +++ b/cases/tasks.py @@ -0,0 +1,36 @@ +from celery.task import task +from django.conf import settings +from django.core.mail import EmailMultiAlternatives, EmailMessage +from django.db.models import Q +from django.shortcuts import reverse +from django.template.loader import render_to_string + +from accounts.models import User +from cases.models import Case + + +@task +def send_email_to_assigned_user(recipients, case_id, domain='demo.django-crm.io', protocol='http'): + """ Send Mail To Users When they are assigned to a case """ + case = Case.objects.get(id=case_id) + for user in recipients: + recipients_list = [] + user = User.objects.filter(id=user).first() + if user: + recipients_list.append(user.email) + context = {} + context["url"] = protocol + '://' + domain + \ + reverse('cases:view_case', args=(case.id,)) + context["user"] = user + context["case"] = case + subject = 'Assigned to case.' + html_content = render_to_string( + 'assigned_to/cases_assigned.html', context=context) + + msg = EmailMessage( + subject, + html_content, + to=recipients_list + ) + msg.content_subtype = "html" + msg.send() diff --git a/cases/templates/view_case.html b/cases/templates/view_case.html index e8b72bcf..b6eae98c 100644 --- a/cases/templates/view_case.html +++ b/cases/templates/view_case.html @@ -1,6 +1,15 @@ {% extends 'sales/base.html' %} {% load staticfiles %} +{% load thumbnail %} {% block content %} +{% block extralinks %} + + +{% endblock extralinks %}
    + +{% endblock content %} +{% block js_block %} + + + +{% endblock js_block %} \ No newline at end of file diff --git a/invoices/templates/invoices_list.html b/invoices/templates/invoices_list.html new file mode 100644 index 00000000..b6e039b3 --- /dev/null +++ b/invoices/templates/invoices_list.html @@ -0,0 +1,369 @@ +{% extends 'sales/base.html' %} +{% load staticfiles %} +{% load paginate %} +{% load thumbnail %} +{% block extralinks %} + +{% endblock %} +{% block content %} + +
    + + + +
    +
    +
    +
    +
    +
    +
    Filters
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    + +
    +
    + + Clear +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + Invoices - {% if show_pageitems %} + {% show_pageitems %}{% else %} + {{ invoices|length }}{% endif %} + + + +
    +
    + + + {% if invoices|length > 0 %} + + + + + + + + + + + + {% endif %} + + + {% if per_page %} + {% paginate per_page invoices %} + {% else %} + {% paginate 10 invoices %} + {% endif %} + {% for invoice in invoices %} + + + + + + + + + + + + {% endfor %} + +
    IDInvoice TitleInvoice NumberEmailStatusAssigned ToCreated byCreated OnActions
    {{ forloop.counter }} + {{ invoice.invoice_title }} {{ invoice.invoice_number }} {{ invoice.email }} {{ invoice.status }} + {% with users=invoice.assigned_to.all %} + {% for user in users %} + {% if user.profile_pic %} + {% thumbnail user.profile_pic "40x40" crop="center" as im %} + + {% endthumbnail %} + {% else %} + + {% endif %} + {% empty %} + None + {% endfor %} + {% endwith %} + + {% if invoice.created_by.profile_pic %} + {% thumbnail invoice.created_by.profile_pic "40x40" crop="center" as im %} + + {% endthumbnail %} + {% else %} + Micro profile pic + {% endif %} + {{ invoice.created_on|date:'d-m-Y' }} + + {% if request.user == invoice.created_by or request.user.role == "ADMIN" or request.user.is_superuser %} + + {% if invoice.is_draft %} + + {% endif %} + {% if invoice.is_sent == True %} + + + + {% endif %} + {% if invoice.is_resent %} + + + {% endif %} + {% if invoice.is_paid_or_cancelled == True %} + + {% endif %} + {% endif %} +
    +
    + {%ifequal invoices|length 0%} +
    No Invoices Found
    + {%endifequal%} +
    + {% show_pages %} +
    +
    +
    +
    + + + +
    +
    +
    +
    +
    +{% endblock %} +{% block js_block %} + + +{% endblock js_block %} \ No newline at end of file diff --git a/invoices/tests.py b/invoices/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/invoices/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/invoices/urls.py b/invoices/urls.py new file mode 100644 index 00000000..c08d359b --- /dev/null +++ b/invoices/urls.py @@ -0,0 +1,26 @@ +from django.urls import path +from invoices.views import * + +app_name = 'invoices' + + +urlpatterns = [ + path('', invoices_list, name='invoices_list'), + path('create/', invoices_create, name='invoices_create'), + path('detail//', invoice_details, name='invoice_details'), + path('edit//', invoice_edit, name='invoice_edit'), + path('delete//', invoice_delete, name='invoice_delete'), + path('download//', invoice_download, name='invoice_download'), + path('send-mail//', invoice_send_mail, name='invoice_send_mail'), + path('cancelled-mail//', invoice_change_status_cancelled, name='invoice_change_status_cancelled'), + path('paid-mail//', invoice_change_status_paid, name='invoice_change_status_paid'), + + path('comment/add/', AddCommentView.as_view(), name="add_comment"), + path('comment/edit/', UpdateCommentView.as_view(), name="edit_comment"), + path('comment/remove/', DeleteCommentView.as_view(), name="remove_comment"), + + path('attachment/add/', AddAttachmentView.as_view(), name="add_attachment"), + path('attachment/remove/', DeleteAttachmentsView.as_view(), + name="remove_attachment"), + +] diff --git a/invoices/views.py b/invoices/views.py new file mode 100644 index 00000000..4c2e1a2c --- /dev/null +++ b/invoices/views.py @@ -0,0 +1,415 @@ +import io +import os + +import pdfkit +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.sites.shortcuts import get_current_site +from django.core.exceptions import PermissionDenied +from django.db.models import Q +from django.http.response import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render, reverse +from django.template.loader import render_to_string +from django.views.generic import (CreateView, DeleteView, DetailView, FormView, + TemplateView, UpdateView, View) + +from common.models import Address, Attachments, Comment, User +from common.tasks import send_email_user_mentions +from invoices.forms import (InvoiceAddressForm, InvoiceAttachmentForm, + InvoiceCommentForm, InvoiceForm) +from invoices.models import Invoice +from invoices.tasks import send_email, send_invoice_email, send_invoice_email_cancel + + +@login_required +def invoices_list(request): + + if request.user.role == 'ADMIN' or request.user.is_superuser: + users = User.objects.all() + elif request.user.google.all(): + users = User.objects.none() + elif request.user.role == 'USER': + users = User.objects.filter(role='ADMIN') + + status = ( + ('Draft', 'Draft'), + ('Sent', 'Sent'), + ('Paid', 'Paid'), + ('Pending', 'Pending'), + ('Cancel', 'Cancel'), + ) + + if request.method == 'GET': + context = {} + if request.user.role == 'ADMIN' or request.user.is_superuser: + invoices = Invoice.objects.all().distinct() + else: + invoices = Invoice.objects.filter( + Q(created_by=request.user) | Q(assigned_to=request.user)).distinct() + context['invoices'] = invoices.order_by('id') + context['status'] = status + context['users'] = users + return render(request, 'invoices_list.html', context) + + if request.method == 'POST': + context = {} + context['status'] = status + context['users'] = users + invoices = Invoice.objects.filter() + if request.user.role == 'ADMIN' or request.user.is_superuser: + invoices = invoices + else: + invoices = invoices.filter( + Q(created_by=request.user) | Q(assigned_to=request.user)).distinct() + + if request.POST.get('invoice_title_number', None): + invoices = invoices.filter( + Q(invoice_title__icontains=request.POST.get('invoice_title_number')) | + Q(invoice_number__icontains=request.POST.get('invoice_title_number'))) + + if request.POST.get('created_by', None): + invoices = invoices.filter( + created_by__id=request.POST.get('created_by')) + + if request.POST.getlist('assigned_to', None): + invoices = invoices.filter( + assigned_to__in=request.POST.getlist('assigned_to')) + context['assigned_to'] = request.POST.getlist('assigned_to') + + if request.POST.get('status', None): + invoices = invoices.filter(status=request.POST.get('status')) + + if request.POST.get('total_amount', None): + invoices = invoices.filter( + total_amount=request.POST.get('total_amount')) + + context['invoices'] = invoices.distinct().order_by('id') + return render(request, 'invoices_list.html', context) + + +@login_required +def invoices_create(request): + if request.method == 'GET': + context = {} + context["form"] = InvoiceForm(request_user=request.user) + # "prefix" use case in form, both from address and to address use the + # same model and same model form, so the name attribute in the form will + # be same for the both forms and the address will be same for both + # shipping and to address, so use prefix parameter to distinguish the + # forms, don't remove this comment + context["from_address_form"] = InvoiceAddressForm( + prefix='from') + context["to_address_form"] = InvoiceAddressForm(prefix='to') + + return render(request, 'invoices_create.html', context) + + if request.method == 'POST': + form = InvoiceForm(request.POST, request_user=request.user) + from_address_form = InvoiceAddressForm( + request.POST, prefix='from') + to_address_form = InvoiceAddressForm(request.POST, prefix='to') + if form.is_valid() and from_address_form.is_valid() and to_address_form.is_valid(): + from_address_obj = from_address_form.save() + to_address_obj = to_address_form.save() + invoice_obj = form.save(commit=False) + invoice_obj.created_by = request.user + invoice_obj.from_address = from_address_obj + invoice_obj.to_address = to_address_obj + invoice_obj.save() + form.save_m2m() + kwargs = {'domain': request.get_host(), 'protocol': request.scheme} + send_email.delay(invoice_obj.id, **kwargs) + return JsonResponse({'error': False, 'success_url': reverse('invoices:invoices_list')}) + else: + return JsonResponse({'error': True, 'errors': form.errors, + 'from_address_errors': from_address_form.errors, + 'to_address_errors': to_address_form.errors}) + + +@login_required +def invoice_details(request, invoice_id): + invoice = get_object_or_404(Invoice.objects.select_related( + 'from_address', 'to_address'), pk=invoice_id) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or invoice.created_by == request.user or request.user in invoice.assigned_to.all()): + raise PermissionDenied + + if request.method == 'GET': + context = {} + context['invoice'] = invoice + context['attachments'] = invoice.invoice_attachment.all() + context['comments'] = invoice.invoice_comments.all() + if request.user.is_superuser or request.user.role == 'ADMIN': + context['users_mention'] = list( + User.objects.all().values('username')) + elif request.user != invoice.created_by: + context['users_mention'] = [ + {'username': invoice.created_by.username}] + else: + context['users_mention'] = list( + invoice.assigned_to.all().values('username')) + return render(request, 'invoices_detail.html', context) + + +@login_required +def invoice_edit(request, invoice_id): + invoice_obj = get_object_or_404(Invoice, pk=invoice_id) + + if not (request.user.role == 'ADMIN' or request.user.is_superuser or invoice_obj.created_by == request.user or request.user in invoice_obj.assigned_to.all()): + raise PermissionDenied + + if request.method == 'GET': + context = {} + context['invoice_obj'] = invoice_obj + context['form'] = InvoiceForm( + instance=invoice_obj, request_user=request.user) + context['from_address_form'] = InvoiceAddressForm(prefix='from', + instance=invoice_obj.from_address) + context['to_address_form'] = InvoiceAddressForm(prefix='to', + instance=invoice_obj.to_address) + return render(request, 'invoices_create.html', context) + + if request.method == 'POST': + form = InvoiceForm(request.POST, instance=invoice_obj, + request_user=request.user) + from_address_form = InvoiceAddressForm( + request.POST, instance=invoice_obj.from_address, prefix='from') + to_address_form = InvoiceAddressForm( + request.POST, instance=invoice_obj.to_address, prefix='to') + if form.is_valid() and from_address_form.is_valid() and to_address_form.is_valid(): + from_address_obj = from_address_form.save() + to_address_obj = to_address_form.save() + invoice_obj = form.save(commit=False) + invoice_obj.created_by = request.user + invoice_obj.from_address = from_address_obj + invoice_obj.to_address = to_address_obj + invoice_obj.save() + form.save_m2m() + kwargs = {'domain': request.get_host(), 'protocol': request.scheme} + send_email.delay(invoice_obj.id, **kwargs) + return JsonResponse({'error': False, 'success_url': reverse('invoices:invoices_list')}) + else: + return JsonResponse({'error': True, 'errors': form.errors, + 'from_address_errors': from_address_form.errors, + 'to_address_errors': to_address_form.errors}) + + +@login_required +def invoice_delete(request, invoice_id): + invoice = get_object_or_404(Invoice, pk=invoice_id) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or invoice.created_by == request.user): + raise PermissionDenied + + if request.method == 'GET': + invoice.delete() + return redirect('invoices:invoices_list') + + +@login_required +def invoice_send_mail(request, invoice_id): + invoice = get_object_or_404(Invoice, pk=invoice_id) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or invoice.created_by == request.user): + raise PermissionDenied + + if request.method == 'GET': + Invoice.objects.filter(id=invoice_id).update( + is_email_sent=True, status='Sent') + kwargs = {'domain': request.get_host(), 'protocol': request.scheme} + send_invoice_email.delay(invoice_id, **kwargs) + return redirect('invoices:invoices_list') + + +@login_required +def invoice_change_status_paid(request, invoice_id): + invoice = get_object_or_404(Invoice, pk=invoice_id) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or invoice.created_by == request.user): + raise PermissionDenied + + if request.method == 'GET': + Invoice.objects.filter(id=invoice_id).update(status='Paid') + kwargs = {'domain': request.get_host(), 'protocol': request.scheme} + # send_invoice_email.delay(invoice_id, **kwargs) + return redirect('invoices:invoices_list') + +@login_required +def invoice_change_status_cancelled(request, invoice_id): + invoice = get_object_or_404(Invoice, pk=invoice_id) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or invoice.created_by == request.user): + raise PermissionDenied + + if request.method == 'GET': + Invoice.objects.filter(id=invoice_id).update(status='Cancelled') + kwargs = {'domain': request.get_host(), 'protocol': request.scheme} + send_invoice_email_cancel.delay(invoice_id, **kwargs) + return redirect('invoices:invoices_list') + +@login_required +def invoice_download(request, invoice_id): + invoice = get_object_or_404(Invoice.objects.select_related( + 'from_address', 'to_address'), pk=invoice_id) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or invoice.created_by == request.user or request.user in invoice.assigned_to.all()): + raise PermissionDenied + + if request.method == 'GET': + context = {} + context['invoice'] = invoice + html = render_to_string('invoice_download_pdf.html', context=context) + pdfkit.from_string(html, 'out.pdf', options={'encoding': "UTF-8"}) + pdf = open("out.pdf", 'rb') + response = HttpResponse(pdf.read(), content_type='application/pdf') + response['Content-Disposition'] = 'attachment; filename=Invoice.pdf' + pdf.close() + os.remove("out.pdf") + return response + + +class AddCommentView(LoginRequiredMixin, CreateView): + model = Comment + form_class = InvoiceCommentForm + http_method_names = ["post"] + + def post(self, request, *args, **kwargs): + self.object = None + self.invoice = get_object_or_404( + Invoice, id=request.POST.get('invoice_id')) + if ( + request.user == self.invoice.created_by or request.user.is_superuser or + request.user.role == 'ADMIN' + ): + form = self.get_form() + if form.is_valid(): + return self.form_valid(form) + return self.form_invalid(form) + + data = { + 'error': "You don't have permission to comment for this account."} + return JsonResponse(data) + + def form_valid(self, form): + comment = form.save(commit=False) + comment.commented_by = self.request.user + comment.invoice = self.invoice + comment.save() + comment_id = comment.id + current_site = get_current_site(self.request) + send_email_user_mentions.delay(comment_id, 'invoices', domain=current_site.domain, + protocol=self.request.scheme) + return JsonResponse({ + "comment_id": comment.id, "comment": comment.comment, + "commented_on": comment.commented_on, + "commented_by": comment.commented_by.email + }) + + def form_invalid(self, form): + return JsonResponse({"error": form['comment'].errors}) + + +class UpdateCommentView(LoginRequiredMixin, View): + http_method_names = ["post"] + + def post(self, request, *args, **kwargs): + self.comment_obj = get_object_or_404( + Comment, id=request.POST.get("commentid")) + if request.user == self.comment_obj.commented_by: + form = InvoiceCommentForm(request.POST, instance=self.comment_obj) + if form.is_valid(): + return self.form_valid(form) + + return self.form_invalid(form) + + data = {'error': "You don't have permission to edit this comment."} + return JsonResponse(data) + + def form_valid(self, form): + self.comment_obj.comment = form.cleaned_data.get("comment") + self.comment_obj.save(update_fields=["comment"]) + comment_id = self.comment_obj.id + current_site = get_current_site(self.request) + send_email_user_mentions.delay(comment_id, 'invoices', domain=current_site.domain, + protocol=self.request.scheme) + return JsonResponse({ + "comment_id": self.comment_obj.id, + "comment": self.comment_obj.comment, + }) + + def form_invalid(self, form): + return JsonResponse({"error": form['comment'].errors}) + + +class DeleteCommentView(LoginRequiredMixin, View): + + def post(self, request, *args, **kwargs): + self.object = get_object_or_404( + Comment, id=request.POST.get("comment_id")) + if request.user == self.object.commented_by: + self.object.delete() + data = {"cid": request.POST.get("comment_id")} + return JsonResponse(data) + + data = {'error': "You don't have permission to delete this comment."} + return JsonResponse(data) + + +class AddAttachmentView(LoginRequiredMixin, CreateView): + model = Attachments + form_class = InvoiceAttachmentForm + http_method_names = ["post"] + + def post(self, request, *args, **kwargs): + self.object = None + self.invoice = get_object_or_404( + Invoice, id=request.POST.get('invoice_id')) + if ( + request.user == self.invoice.created_by or + request.user.is_superuser or + request.user.role == 'ADMIN' + ): + form = self.get_form() + if form.is_valid(): + return self.form_valid(form) + + return self.form_invalid(form) + + data = { + 'error': "You don't have permission to add attachment \ + for this account."} + return JsonResponse(data) + + def form_valid(self, form): + attachment = form.save(commit=False) + attachment.created_by = self.request.user + attachment.file_name = attachment.attachment.name + attachment.invoice = self.invoice + attachment.save() + return JsonResponse({ + "attachment_id": attachment.id, + "attachment": attachment.file_name, + "attachment_url": attachment.attachment.url, + "download_url": reverse('common:download_attachment', + kwargs={'pk': attachment.id}), + "attachment_display": attachment.get_file_type_display(), + "created_on": attachment.created_on, + "created_by": attachment.created_by.email, + "file_type": attachment.file_type() + }) + + def form_invalid(self, form): + return JsonResponse({"error": form['attachment'].errors}) + + +class DeleteAttachmentsView(LoginRequiredMixin, View): + + def post(self, request, *args, **kwargs): + self.object = get_object_or_404( + Attachments, id=request.POST.get("attachment_id")) + if ( + request.user == self.object.created_by or + request.user.is_superuser or + request.user.role == 'ADMIN' + ): + self.object.delete() + data = {"acd": request.POST.get("attachment_id")} + return JsonResponse(data) + + data = { + 'error': "You don't have permission to delete this attachment."} + return JsonResponse(data) diff --git a/leads/forms.py b/leads/forms.py index 9ab63ef8..9465c5f9 100644 --- a/leads/forms.py +++ b/leads/forms.py @@ -13,11 +13,12 @@ class LeadForm(forms.ModelForm): if self.data.get('status') == 'converted': self.fields['account_name'].required = True self.fields['email'].required = True - self.fields['assigned_to'].queryset = assigned_users - self.fields['assigned_to'].required = False self.fields['first_name'].required = False self.fields['last_name'].required = False self.fields['title'].required = True + if assigned_users: + self.fields['assigned_to'].queryset = assigned_users + self.fields['assigned_to'].required = False for key, value in self.fields.items(): if key == 'phone': value.widget.attrs['placeholder'] =\ diff --git a/leads/migrations/0009_lead_created_from_site.py b/leads/migrations/0009_lead_created_from_site.py new file mode 100644 index 00000000..970b4f89 --- /dev/null +++ b/leads/migrations/0009_lead_created_from_site.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.7 on 2019-05-29 06:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('leads', '0008_auto_20190315_1503'), + ] + + operations = [ + migrations.AddField( + model_name='lead', + name='created_from_site', + field=models.BooleanField(default=False), + ), + ] diff --git a/leads/models.py b/leads/models.py index 4acfd971..e7aa7822 100644 --- a/leads/models.py +++ b/leads/models.py @@ -53,12 +53,16 @@ class Lead(models.Model): enquery_type = models.CharField(max_length=255, blank=True, null=True) tags = models.ManyToManyField(Tags, blank=True) contacts = models.ManyToManyField(Contact, related_name="lead_contacts") + created_from_site = models.BooleanField(default=False) class Meta: ordering = ['-created_on'] def __str__(self): - return self.first_name + self.last_name + if self.first_name and self.last_name: + return self.first_name + self.last_name + else: + return self.title def get_complete_address(self): return return_complete_address(self) diff --git a/leads/tasks.py b/leads/tasks.py index b4d0a54a..11222ede 100644 --- a/leads/tasks.py +++ b/leads/tasks.py @@ -1,8 +1,10 @@ from celery.task import task from django.conf import settings +from django.core.mail import EmailMultiAlternatives, EmailMessage from django.db.models import Q -from django.core.mail import EmailMultiAlternatives +from django.shortcuts import reverse from django.template.loader import render_to_string + from accounts.models import User from leads.models import Lead @@ -59,3 +61,31 @@ def send_lead_assigned_emails(lead_id, new_assigned_to_list, site_address): mail_kwargs["html_content"] = html_content mail_kwargs["recipients"] = [user.email] send_email.delay(**mail_kwargs) + + + +@task +def send_email_to_assigned_user(recipients, lead_id, domain='demo.django-crm.io', protocol='http'): + """ Send Mail To Users When they are assigned to a lead """ + lead = Lead.objects.get(id=lead_id) + for user in recipients: + recipients_list = [] + user = User.objects.filter(id=user).first() + if user: + recipients_list.append(user.email) + context = {} + context["url"] = protocol + '://' + domain + \ + reverse('leads:view_lead', args=(lead.id,)) + context["user"] = user + context["lead"] = lead + subject = 'Assigned to lead.' + html_content = render_to_string( + 'assigned_to/leads_assigned.html', context=context) + + msg = EmailMessage( + subject, + html_content, + to=recipients_list + ) + msg.content_subtype = "html" + msg.send() diff --git a/leads/templates/leads.html b/leads/templates/leads.html index a0153845..53a4fdc3 100644 --- a/leads/templates/leads.html +++ b/leads/templates/leads.html @@ -11,7 +11,8 @@ @@ -26,16 +27,19 @@
    - +
    - {% for each_source in source %} - + {% endfor %}
    @@ -44,19 +48,22 @@
    - {% for each_status in status %} - + {% endfor%}
    @@ -64,10 +71,11 @@
    - + {% for tag in tags %} - + {% endfor%}
    @@ -91,10 +99,12 @@
    @@ -102,9 +112,10 @@
    - Open Leads - {% if show_pageitems %} {% show_pageitems %}{% else %} {{ open_leads|length }}{% endif %} + Open Leads - {% if show_pageitems %} {% show_pageitems %}{% else %} + {{ open_leads|length }}{% endif %} - +
    @@ -117,7 +128,7 @@ Created By Source Status - Assigned User + Assigned To Tags Created On Actions @@ -134,14 +145,16 @@ {{ forloop.counter }} - {{ lead.title }} + {{ lead.title }} {% if lead.created_by.profile_pic %} - {% thumbnail lead.created_by.profile_pic "40x40" crop="center" as im %} - - {% endthumbnail %} + {% thumbnail lead.created_by.profile_pic "40x40" crop="center" as im %} + + {% endthumbnail %} {% else %} - Micro profile pic + Micro profile pic {% endif %} {{ lead.source }} @@ -150,18 +163,19 @@ {% with lead_users=lead.assigned_to.all %} {% for user in lead_users %} {% if user.profile_pic %} - {% thumbnail user.profile_pic "40x40" crop="center" as im %} - - {% endthumbnail %} + {% thumbnail user.profile_pic "40x40" crop="center" as im %} + + {% endthumbnail %} {% else %} - + {% endif %} {% empty %} - Not Assigned to Any + None {% endfor %} {% endwith %} - + {% with tags=lead.tags.all %} {% if tags %} {% for tag in tags %} @@ -173,10 +187,12 @@ {% endwith %} {{ lead.created_on }} - - + + {% if request.user == lead.created_by or request.user.role == "ADMIN" or request.user.is_superuser %} - + {% endif %} @@ -197,9 +213,10 @@
    - Closed Leads - {% if show_pageitems %} {% show_pageitems %}{% else %} {{ close_leads|length }}{% endif %} + Closed Leads - {% if show_pageitems %} {% show_pageitems %}{% else %} + {{ close_leads|length }}{% endif %} - +
    @@ -228,7 +245,7 @@ {{ forloop.counter }} - {{ lead.title }} + {{ lead.title }} {{ lead.source }} {{ lead.get_status_display }} @@ -236,11 +253,11 @@ {% for user in lead_users %} {{ user.email }}
    {% empty %} - Not Assigned to Any + None {% endfor %} {% endwith %} - + {% with tags=lead.tags.all %} {% if tags %} {% for tag in tags %} @@ -252,10 +269,12 @@ {% endwith %} {{ lead.created_on }} - - + + {% if request.user == lead.created_by %} - + {% endif %} @@ -281,43 +300,44 @@ {% block js_block %} - - + + - - -{% endblock js_block %} + + +{% endblock js_block %} \ No newline at end of file diff --git a/leads/tests.py b/leads/tests.py index 2eea301e..b48cd689 100644 --- a/leads/tests.py +++ b/leads/tests.py @@ -148,11 +148,11 @@ class LeadsPostrequestTestCase(TestLeadModel, TestCase): # print(get_name.first_name+get_name.last_name,str(get_name)) self.assertEqual(get_name.first_name + get_name.last_name, str(get_name)) - response = self.client.get('/leads/list/') + response = self.client.get(reverse('leads:list')) self.assertEqual(response.status_code, 200) def test_leads_list_html(self): - response = self.client.get('/leads/list/') + response = self.client.get(reverse('leads:list')) self.assertTemplateUsed(response, 'leads.html') @@ -239,7 +239,7 @@ class LeadListTestCase(TestLeadModel, TestCase): def test_leads_list(self): self.lead = Lead.objects.all() - response = self.client.get('/leads/list/') + response = self.client.get(reverse('leads:list')) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'leads.html') @@ -248,7 +248,7 @@ class LeadListTestCase(TestLeadModel, TestCase): data = {'fist_name': "marphy", 'last_name': "racheal", 'city': "hyd", 'email': "contact@gmail.com", 'status': "Assigned"} - response = self.client.post('/leads/list/', data) + response = self.client.post(reverse('leads:list'), data) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'leads.html') @@ -265,7 +265,7 @@ class LeadsRemoveTestCase(TestLeadModel, TestCase): def test_leads_remove(self): response = self.client.get('/leads/' + str(self.lead.id) + '/delete/') - self.assertEqual(response['location'], '/leads/list/') + self.assertEqual(response['location'], '/leads/') def test_leads_remove_status(self): self.client.login(username='mp@micropyramid.com', password='mp') @@ -372,7 +372,7 @@ class AttachmentTestCase(TestLeadModel, TestCase): class TestTemplates(TestLeadModel, TestCase): def test_lead_list_view(self): - resp = self.client.post('/leads/list/', {'name': 'trevor', + resp = self.client.post(reverse('leads:list'), {'name': 'trevor', 'tag': "123", 'source': "call", 'assigned_to': '1', @@ -483,7 +483,7 @@ class TestLeadListView(TestCase): self.client.login(username='g@mp.com', password='jorge2293') def test_lead_list_view(self): - response = self.client.get('/leads/list/') + response = self.client.get(reverse('leads:list')) self.assertEqual(response.status_code, 200) # response = self.client.post('/leads/create/',{}) diff --git a/leads/urls.py b/leads/urls.py index 321b84a2..347c1f76 100644 --- a/leads/urls.py +++ b/leads/urls.py @@ -11,7 +11,7 @@ app_name = 'leads' urlpatterns = [ - path('list/', LeadListView.as_view(), name='list'), + path('', LeadListView.as_view(), name='list'), path('create/', create_lead, name='add_lead'), # create_lead_from_site path('create/from-site/', create_lead_from_site, diff --git a/leads/views.py b/leads/views.py index 30105775..1988f41f 100644 --- a/leads/views.py +++ b/leads/views.py @@ -22,7 +22,8 @@ from planner.models import Event, Reminder from planner.forms import ReminderForm from leads.tasks import send_lead_assigned_emails from django.core.exceptions import PermissionDenied - +from common.tasks import send_email_user_mentions +from leads.tasks import send_email_to_assigned_user class LeadListView(LoginRequiredMixin, TemplateView): model = Lead @@ -52,13 +53,13 @@ class LeadListView(LoginRequiredMixin, TemplateView): if request_post.get('status'): queryset = queryset.filter(status=request_post.get('status')) if request_post.get('tag'): - queryset = queryset.filter(tags__in=request_post.get('tag')) + queryset = queryset.filter(tags__in=request_post.getlist('tag')) if request_post.get('source'): queryset = queryset.filter(source=request_post.get('source')) if request_post.getlist('assigned_to'): queryset = queryset.filter( assigned_to__id__in=request_post.getlist('assigned_to')) - return queryset + return queryset.distinct() def get_context_data(self, **kwargs): context = super(LeadListView, self).get_context_data(**kwargs) @@ -74,6 +75,7 @@ class LeadListView(LoginRequiredMixin, TemplateView): is_active=True).order_by('email') context["assignedto_list"] = [ int(i) for i in self.request.POST.getlist('assigned_to', []) if i] + context["request_tags"] = self.request.POST.getlist('tag') search = True if ( self.request.POST.get('name') or self.request.POST.get('city') or @@ -102,7 +104,13 @@ class LeadListView(LoginRequiredMixin, TemplateView): def create_lead(request): template_name = "create_lead.html" - users = User.objects.filter(is_active=True).order_by('email') + users = [] + if request.user.role == 'ADMIN' or request.user.is_superuser: + users = User.objects.filter(is_active=True).order_by('email') + elif request.user.google.all(): + users = [] + else: + users = User.objects.filter(role='ADMIN').order_by('email') form = LeadForm(assigned_to=users) if request.POST: @@ -125,20 +133,23 @@ def create_lead(request): lead_obj.assigned_to.add(*request.POST.getlist('assigned_to')) assigned_to_list = request.POST.getlist('assigned_to') current_site = get_current_site(request) - for assigned_to_user in assigned_to_list: - user = get_object_or_404(User, pk=assigned_to_user) - mail_subject = 'Assigned to lead.' - message = render_to_string( - 'assigned_to/leads_assigned.html', { - 'user': user, - 'domain': current_site.domain, - 'protocol': request.scheme, - 'lead': lead_obj - }) - email = EmailMessage( - mail_subject, message, to=[user.email]) - email.content_subtype = "html" - email.send() + recipients = assigned_to_list + send_email_to_assigned_user.delay(recipients, lead_obj.id, domain=current_site.domain, + protocol=request.scheme) + # for assigned_to_user in assigned_to_list: + # user = get_object_or_404(User, pk=assigned_to_user) + # mail_subject = 'Assigned to lead.' + # message = render_to_string( + # 'assigned_to/leads_assigned.html', { + # 'user': user, + # 'domain': current_site.domain, + # 'protocol': request.scheme, + # 'lead': lead_obj + # }) + # email = EmailMessage( + # mail_subject, message, to=[user.email]) + # email.content_subtype = "html" + # email.send() if request.FILES.get('lead_attachment'): attachment = Attachments() @@ -169,20 +180,23 @@ def create_lead(request): # account_object.assigned_to.add(*request.POST.getlist('assigned_to')) assigned_to_list = request.POST.getlist('assigned_to') current_site = get_current_site(request) - for assigned_to_user in assigned_to_list: - user = get_object_or_404(User, pk=assigned_to_user) - mail_subject = 'Assigned to account.' - message = render_to_string( - 'assigned_to/account_assigned.html', { - 'user': user, - 'domain': current_site.domain, - 'protocol': request.scheme, - 'account': account_object - }) - email = EmailMessage( - mail_subject, message, to=[user.email]) - email.content_subtype = "html" - email.send() + recipients = assigned_to_list + send_email_to_assigned_user.delay(recipients, lead_obj.id, domain=current_site.domain, + protocol=request.scheme) + # for assigned_to_user in assigned_to_list: + # user = get_object_or_404(User, pk=assigned_to_user) + # mail_subject = 'Assigned to account.' + # message = render_to_string( + # 'assigned_to/account_assigned.html', { + # 'user': user, + # 'domain': current_site.domain, + # 'protocol': request.scheme, + # 'account': account_object + # }) + # email = EmailMessage( + # mail_subject, message, to=[user.email]) + # email.content_subtype = "html" + # email.send() account_object.save() success_url = reverse('leads:list') @@ -242,11 +256,19 @@ class LeadDetailView(LoginRequiredMixin, DetailView): assigned_dict['name'] = each.email assigned_data.append(assigned_dict) + if self.request.user.is_superuser or self.request.user.role == 'ADMIN': + users_mention = list(User.objects.all().values('username')) + elif self.request.user != context['object'].created_by: + users_mention = [{'username': context['object'].created_by.username}] + else: + users_mention = list(context['object'].assigned_to.all().values('username')) + context.update({ "attachments": attachments, "comments": comments, "status": LEAD_STATUS, "countries": COUNTRIES, "reminder_form_set": reminder_form_set, "meetings": meetings, "calls": calls, + "users_mention": users_mention, "assigned_data": json.dumps(assigned_data)}) return context @@ -254,7 +276,13 @@ class LeadDetailView(LoginRequiredMixin, DetailView): def update_lead(request, pk): lead_record = Lead.objects.filter(pk=pk).first() template_name = "create_lead.html" - users = User.objects.filter(is_active=True).order_by('email') + users = [] + if request.user.role == 'ADMIN' or request.user.is_superuser: + users = User.objects.filter(is_active=True).order_by('email') + elif request.user.google.all(): + users = [] + else: + users = User.objects.filter(role='ADMIN').order_by('email') status = request.GET.get('status', None) initial = {} if status and status == "converted": @@ -303,21 +331,25 @@ def update_lead(request, pk): all_members_list = list( set(list(assigned_form_users)) - set(list(assigned_to_ids))) - if all_members_list: - for assigned_to_user in all_members_list: - user = get_object_or_404(User, pk=assigned_to_user) - mail_subject = 'Assigned to lead.' - message = render_to_string( - 'assigned_to/leads_assigned.html', { - 'user': user, - 'domain': current_site.domain, - 'protocol': request.scheme, - 'lead': lead_obj - }) - email = EmailMessage( - mail_subject, message, to=[user.email]) - email.content_subtype = "html" - email.send() + current_site = get_current_site(request) + recipients = all_members_list + send_email_to_assigned_user.delay(recipients, lead_obj.id, domain=current_site.domain, + protocol=request.scheme) + # if all_members_list: + # for assigned_to_user in all_members_list: + # user = get_object_or_404(User, pk=assigned_to_user) + # mail_subject = 'Assigned to lead.' + # message = render_to_string( + # 'assigned_to/leads_assigned.html', { + # 'user': user, + # 'domain': current_site.domain, + # 'protocol': request.scheme, + # 'lead': lead_obj + # }) + # email = EmailMessage( + # mail_subject, message, to=[user.email]) + # email.content_subtype = "html" + # email.send() lead_obj.assigned_to.clear() lead_obj.assigned_to.add(*request.POST.getlist('assigned_to')) @@ -353,21 +385,28 @@ def update_lead(request, pk): # account_object.assigned_to.add(*request.POST.getlist('assigned_to')) assigned_to_list = request.POST.getlist('assigned_to') current_site = get_current_site(request) - for assigned_to_user in assigned_to_list: - user = get_object_or_404(User, pk=assigned_to_user) - mail_subject = 'Assigned to account.' - message = render_to_string( - 'assigned_to/account_assigned.html', { - 'user': user, - 'domain': current_site.domain, - 'protocol': request.scheme, - 'account': account_object - }) - email = EmailMessage( - mail_subject, message, to=[user.email]) - email.content_subtype = "html" - email.send() + recipients = assigned_to_list + send_email_to_assigned_user.delay(recipients, lead_obj.id, domain=current_site.domain, + protocol=request.scheme) + # current_site = get_current_site(request) + # for assigned_to_user in assigned_to_list: + # user = get_object_or_404(User, pk=assigned_to_user) + # mail_subject = 'Assigned to account.' + # message = render_to_string( + # 'assigned_to/account_assigned.html', { + # 'user': user, + # 'domain': current_site.domain, + # 'protocol': request.scheme, + # 'account': account_object + # }) + # email = EmailMessage( + # mail_subject, message, to=[user.email]) + # email.content_subtype = "html" + # email.send() + for comment in lead_obj.leads_comments.all(): + comment.account = account_object + comment.save() account_object.save() status = request.GET.get('status', None) success_url = reverse('leads:list') @@ -435,7 +474,7 @@ def convert_lead(request, pk): ) contacts_list = lead_obj.contacts.all().values_list('id', flat=True) account_object.contacts.add(*contacts_list) - account_object.save() + account_obj = account_object.save() current_site = get_current_site(request) for assigned_to_user in lead_obj.assigned_to.all().values_list( 'id', flat=True): @@ -485,6 +524,10 @@ class AddCommentView(LoginRequiredMixin, CreateView): comment.commented_by = self.request.user comment.lead = self.lead comment.save() + comment_id = comment.id + current_site = get_current_site(self.request) + send_email_user_mentions.delay(comment_id, 'leads', domain=current_site.domain, + protocol=self.request.scheme) return JsonResponse({ "comment_id": comment.id, "comment": comment.comment, "commented_on": comment.commented_on, @@ -514,6 +557,10 @@ class UpdateCommentView(LoginRequiredMixin, View): def form_valid(self, form): self.comment_obj.comment = form.cleaned_data.get("comment") self.comment_obj.save(update_fields=["comment"]) + comment_id = self.comment_obj.id + current_site = get_current_site(self.request) + send_email_user_mentions.delay(comment_id, 'leads', domain=current_site.domain, + protocol=self.request.scheme) return JsonResponse({ "commentid": self.comment_obj.id, "comment": self.comment_obj.comment, @@ -579,7 +626,8 @@ class AddAttachmentsView(LoginRequiredMixin, CreateView): "created_by": attachment.created_by.email, "download_url": reverse( 'common:download_attachment', kwargs={'pk': attachment.id}), - "attachment_display": attachment.get_file_type_display() + "attachment_display": attachment.get_file_type_display(), + "file_type": attachment.file_type() }) def form_invalid(self, form): diff --git a/marketing/forms.py b/marketing/forms.py index bc9e0762..5b252ede 100644 --- a/marketing/forms.py +++ b/marketing/forms.py @@ -1,4 +1,5 @@ import csv +import datetime import re import json import openpyxl @@ -108,7 +109,8 @@ def xls_doc_validate(document): validated_rows = [] invalid_rows = [] for sheet_name in sheets: - validated_rows, invalid_rows = get_validated_rows(wb, sheet_name, validated_rows, invalid_rows) + validated_rows, invalid_rows = get_validated_rows( + wb, sheet_name, validated_rows, invalid_rows) return {"error": False, "validated_rows": validated_rows, "invalid_rows": invalid_rows} @@ -118,11 +120,11 @@ def import_document_validator(document): document.seek(0, 0) return csv_doc_validate(document) except Exception as e: - print (e) + print(e) try: return xls_doc_validate(document) except Exception as e: - print (e) + print(e) return {"error": True, "message": "Not a valid CSV/XLS file"} @@ -139,8 +141,9 @@ class ContactListForm(forms.ModelForm): self.fields['contacts_file'].widget.attrs.update({ "accept": ".csv,.xls,.xlsx,.xlsm,.xlsb,.xml", }) - if self.instance is None: - self.fields['contacts_file'].required = True + self.fields['contacts_file'].required = True + # if self.instance is None: + # self.fields['contacts_file'].required = True if self.data.get('contacts_file'): self.fields['contacts_file'].widget.attrs.update({ "accept": ".csv,.xls,.xlsx,.xlsm,.xlsb,.xml", @@ -155,31 +158,10 @@ class ContactListForm(forms.ModelForm): else: self.validated_rows = data.get("validated_rows", []) self.invalid_rows = data.get("invalid_rows", []) + if self.invalid_rows: + raise forms.ValidationError('Uploaded file is not valid') return document - def clean_tags(self): - tags_data = self.data['tags'].split(",") if self.data['tags'] else [] - if len(tags_data) > 0: - instance_tags = [] - if self.instance and self.instance.id is not None: - instance_tags = list( - set(self.instance.tags.all().values_list( - 'name', flat=True))) - for each in tags_data: - tags = Tag.objects.filter(name__iexact=each) - if instance_tags: - tags = tags.exclude(name__in=instance_tags) - if tags: - raise forms.ValidationError( - str(each) + ' Tag aleady existed with this name') - if bool(re.search(r"[~\!_.@#\$%\^&\*\(\)\+{}\":;'/\[\]]", - each)): - raise forms.ValidationError( - "Tags Should not contain special \ - charcters except hyphens") - return self.data['tags'] - raise forms.ValidationError("Enter Any tags") - def clean_visible_to(self): visible_to_data = json.loads(self.data['visible_to']) if len(visible_to_data) > 0: @@ -203,26 +185,45 @@ class ContactListForm(forms.ModelForm): class ContactForm(forms.ModelForm): contact_list = forms.CharField(max_length=5000) + def __init__(self, *args, **kwargs): + request_user = kwargs.pop('request_user', None) + self.obj_instance = kwargs.get('instance', None) + super(ContactForm, self).__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs = {"class": "form-control"} + + self.fields['name'].required = True + self.fields['last_name'].required = True + self.fields['city'].required = True + self.fields['state'].required = True + self.fields['email'].required = True + self.fields['contact_list'].required = False + class Meta: model = Contact - fields = ["name", "email", "contact_number"] + fields = ["name", "email", "contact_number", + "last_name", "city", "state"] - def __init__(self, *args, **kwargs): - super(ContactForm, self).__init__(*args, **kwargs) - - def clean_contact_list(self): - contact_list = self.cleaned_data.get("contact_list") - if not contact_list or contact_list == '[]' or\ - json.loads(contact_list) == []: + def clean_email(self): + email = self.cleaned_data.get('email') + if Contact.objects.filter(email=email).exclude(id=self.instance.id).exists(): raise forms.ValidationError( - "Please choose any of the Contact List") - else: - for each in json.loads(contact_list): - if not ContactList.objects.filter(id=each).first(): - raise forms.ValidationError( - "Please choose a valid Contact List") + 'Contact with this Email already exists') + return email - return contact_list + # def clean_contact_list(self): + # contact_list = self.cleaned_data.get("contact_list") + # if not contact_list or contact_list == '[]' or\ + # json.loads(contact_list) == []: + # raise forms.ValidationError( + # "Please choose any of the Contact List") + # else: + # for each in json.loads(contact_list): + # if not ContactList.objects.filter(id=each).first(): + # raise forms.ValidationError( + # "Please choose a valid Contact List") + + # return contact_list class ContactsCSVUploadForm(forms.Form): @@ -234,6 +235,7 @@ class ContactsCSVUploadForm(forms.Form): self.fields['contacts_file'].widget.attrs.update({ "accept": ".csv,.xls,.xlsx,.xlsm,.xlsb,.xml", }) + self.fields['contacts_file'].required = True def clean_contacts_file(self): document = self.cleaned_data.get("contacts_file") @@ -275,12 +277,14 @@ class SendCampaignForm(forms.ModelForm): from_email = forms.EmailField(max_length=100, required=True) from_name = forms.CharField(max_length=100, required=True) contact_list = forms.CharField(max_length=5000, required=True) + tags = forms.CharField(max_length=5000, required=False) class Meta: model = Campaign - fields = ['title', 'subject', 'html', 'email_template', 'attachment'] + fields = ['title', 'subject', 'html', 'email_template', 'attachment', ] def __init__(self, *args, **kwargs): + self.contacts_list = kwargs.pop('contacts_list') super(SendCampaignForm, self).__init__(*args, **kwargs) if self.data.get('schedule_later') and self.data['schedule_later'] == 'true': self.fields['timezone'].required = True @@ -288,16 +292,31 @@ class SendCampaignForm(forms.ModelForm): if not self.data.get('reply_to_crm'): self.fields['reply_to_email'].required = True + def clean_schedule_date_time(self): + schedule_date_time = self.cleaned_data.get('schedule_date_time') + if self.cleaned_data.get('schedule_later') == 'true': + schedule_date_time = datetime.datetime.strptime( + schedule_date_time, '%Y-%m-%d %H:%M') + if schedule_date_time < datetime.datetime.now(): + raise forms.ValidationError( + 'Schedule Date Time should be greater than current time') + return schedule_date_time + def clean_contact_list(self): - contact_list = self.cleaned_data.get("contact_list") + contact_list = self.contacts_list if not contact_list or contact_list == '[]' or \ contact_list == []: raise forms.ValidationError( "Please choose any of the Contact List") else: for each in contact_list: - if not ContactList.objects.filter(id=each).first(): + contacts_list_obj = ContactList.objects.filter(id=each).first() + if not contacts_list_obj: raise forms.ValidationError( "Please choose a valid Contact List") - return contact_list + if contacts_list_obj.contacts.count() < 1: + raise forms.ValidationError( + 'The contact list "{}" does not have any contacts in it .'.format(contacts_list_obj.name)) + + return contact_list \ No newline at end of file diff --git a/marketing/migrations/0007_auto_20190611_1226.py b/marketing/migrations/0007_auto_20190611_1226.py new file mode 100644 index 00000000..871436d1 --- /dev/null +++ b/marketing/migrations/0007_auto_20190611_1226.py @@ -0,0 +1,27 @@ +# Generated by Django 2.1.7 on 2019-06-11 06:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('marketing', '0006_campaign_attachment'), + ] + + operations = [ + migrations.CreateModel( + name='CampaignCompleted', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_completed', models.BooleanField(default=False)), + ('campaign', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='campaign_is_completed', to='marketing.Campaign')), + ], + ), + migrations.AlterField( + model_name='campaignopen', + name='contact', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='contact_campaign', to='marketing.Contact'), + ), + ] diff --git a/marketing/migrations/0008_auto_20190612_1905.py b/marketing/migrations/0008_auto_20190612_1905.py new file mode 100644 index 00000000..da0c38a0 --- /dev/null +++ b/marketing/migrations/0008_auto_20190612_1905.py @@ -0,0 +1,45 @@ +# Generated by Django 2.1.7 on 2019-06-12 13:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('marketing', '0007_auto_20190611_1226'), + ] + + operations = [ + migrations.AlterModelOptions( + name='campaign', + options={'ordering': ('created_on',)}, + ), + migrations.AlterModelOptions( + name='contact', + options={'ordering': ['id']}, + ), + migrations.AlterModelOptions( + name='contactlist', + options={'ordering': ('id',)}, + ), + migrations.AlterModelOptions( + name='emailtemplate', + options={'ordering': ['id']}, + ), + migrations.AddField( + model_name='campaign', + name='tags', + field=models.ManyToManyField(to='marketing.Tag'), + ), + migrations.AlterField( + model_name='campaignopen', + name='campaign', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='campaign_open', to='marketing.Campaign'), + ), + migrations.AlterField( + model_name='campaignopen', + name='contact', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='contact_campaign_open', to='marketing.Contact'), + ), + ] diff --git a/marketing/models.py b/marketing/models.py index 87ed96eb..b63ec2b4 100644 --- a/marketing/models.py +++ b/marketing/models.py @@ -35,6 +35,9 @@ class EmailTemplate(models.Model): subject = models.CharField(max_length=5000) html = models.TextField() + class Meta: + ordering = ['id', ] + @property def created_by_user(self): return self.created_by if self.created_by else None @@ -52,6 +55,9 @@ class ContactList(models.Model): visible_to = models.ManyToManyField( User, related_name="contact_lists_visible_to") + class Meta: + ordering = ('id',) + @property def created_by_user(self): return self.created_by if self.created_by else None @@ -126,6 +132,9 @@ class Contact(models.Model): def __str__(self): return self.email + class Meta: + ordering = ['id', ] + class FailedContact(models.Model): phone_regex = RegexValidator( @@ -191,11 +200,15 @@ class Campaign(models.Model): opens = models.IntegerField(default='0', blank=True) opens_unique = models.IntegerField(default='0', blank=True) bounced = models.IntegerField(default='0') + tags = models.ManyToManyField(Tag) status = models.CharField( default="Preparing", choices=STATUS_CHOICES, max_length=20) attachment = models.FileField( max_length=1000, upload_to=get_campaign_attachment_path, blank=True, null=True) + class Meta: + ordering = ('-created_on', ) + @property def no_of_unsubscribers(self): unsubscribers = self.campaign_contacts.filter( @@ -225,12 +238,28 @@ class Campaign(models.Model): @property def sent_on_format(self): if self.schedule_date_time: - c_schedule_date_time = convert_to_custom_timezone(self.schedule_date_time, self.timezone) + c_schedule_date_time = convert_to_custom_timezone( + self.schedule_date_time, self.timezone) return c_schedule_date_time.strftime('%b %d, %Y %I:%M %p') else: - c_created_on = convert_to_custom_timezone(self.created_on, self.timezone) + c_created_on = convert_to_custom_timezone( + self.created_on, self.timezone) return c_created_on.strftime('%b %d, %Y %I:%M %p') + @property + def get_all_emails_count(self): + return self.contact_lists.exclude(contacts__email=None).values_list('contacts__email').count() + + @property + def get_all_email_bounces_count(self): + return self.contact_lists.filter(contacts__is_bounced=True + ).exclude(contacts__email=None).values_list('contacts__email').count() + + @property + def get_all_emails_unsubscribed_count(self): + return self.contact_lists.filter(contacts__is_unsubscribed=True + ).exclude(contacts__email=None).values_list('contacts__email').count() + @receiver(models.signals.pre_delete, sender=Campaign) def comment_attachments_delete(sender, instance, **kwargs): @@ -274,9 +303,19 @@ class CampaignLinkClick(models.Model): class CampaignOpen(models.Model): - campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE) + campaign = models.ForeignKey( + Campaign, on_delete=models.CASCADE, related_name='campaign_open') ip_address = models.GenericIPAddressField() created_on = models.DateTimeField(auto_now_add=True) user_agent = models.CharField(max_length=2000, blank=True, null=True) contact = models.ForeignKey( - Contact, blank=True, null=True, on_delete=models.CASCADE) + Contact, blank=True, null=True, on_delete=models.CASCADE, related_name='contact_campaign_open') + + +class CampaignCompleted(models.Model): + """ This Model Is Used To Check If The Scheduled Later Emails Have Been Sent + related name : campaign_is_completed + """ + campaign = models.OneToOneField( + Campaign, on_delete=models.CASCADE, related_name='campaign_is_completed') + is_completed = models.BooleanField(default=False) diff --git a/marketing/tasks.py b/marketing/tasks.py index 7341e1ab..6b89ccd4 100644 --- a/marketing/tasks.py +++ b/marketing/tasks.py @@ -1,14 +1,18 @@ import datetime import hashlib +from mimetypes import MimeTypes + import pytz import requests -from mimetypes import MimeTypes from celery.task import task from django.conf import settings from django.core.mail import EmailMessage -from django.template import Template, Context +from django.shortcuts import reverse +from django.template import Context, Template + from common.utils import convert_to_custom_timezone -from marketing.models import Contact, FailedContact, ContactList, Campaign, CampaignLog +from marketing.models import (Campaign, CampaignLog, Contact, ContactList, + FailedContact, CampaignCompleted) @task @@ -44,7 +48,8 @@ def upload_csv_file(data, invalid_data, user, contact_lists): contact.state = each['state'] contact.save() for contact_list in contact_lists: - contact.contact_list.add(ContactList.objects.get(id=int(contact_list))) + contact.contact_list.add( + ContactList.objects.get(id=int(contact_list))) for each in invalid_data: contact = FailedContact.objects.filter(email=each['email']).first() @@ -62,7 +67,8 @@ def upload_csv_file(data, invalid_data, user, contact_lists): contact.state = each['state'] contact.save() for contact_list in contact_lists: - contact.contact_list.add(ContactList.objects.get(id=int(contact_list))) + contact.contact_list.add( + ContactList.objects.get(id=int(contact_list))) def send_campaign_mail(subject, content, from_email, to_email, bcc, reply_to, attachments): @@ -78,7 +84,7 @@ def send_campaign_mail(subject, content, from_email, to_email, bcc, reply_to, at msg.attach(*attachment) msg.content_subtype = "html" res = msg.send() - print (res) + print(res) def get_campaign_message_id(campaign): @@ -92,7 +98,7 @@ def get_campaign_message_id(campaign): @task -def run_campaign(campaign): +def run_campaign(campaign, domain='demo.django-crm.io', protocol='https'): try: campaign = Campaign.objects.get(id=campaign) attachments = [] @@ -123,26 +129,42 @@ def run_campaign(campaign): from_email = campaign.from_email else: from_email = campaign.created_by.email - reply_to_email = str(from_email) + ' <' + str(message_id + '@' + domain_name + '') + '>' + reply_to_email = str(from_email) + ' <' + \ + str(message_id + '@' + domain_name + '') + '>' if not (each_contact.is_bounced or each_contact.is_unsubscribed): - domain_url = settings.URL_FOR_LINKS - link = '' + # domain_url = settings.URL_FOR_LINKS + domain_url = protocol + '://' + domain + img_src_url = domain_url + reverse('marketing:campaign_open', kwargs={ + 'campaign_log_id': campaign_log.id, 'email_id': each_contact.id}) + # images can only be accessed over https + link = 'company_logo'.format( + img_src_url=img_src_url) + # link = 'company_logo' + + unsubscribe_from_campaign_url = reverse( + 'marketing:unsubscribe_from_campaign', kwargs={'contact_id': each_contact.id, }) + unsubscribe_from_campaign_html = "

    Unsubscribe".format( + domain_url + unsubscribe_from_campaign_url) names_dict = {'company_name': each_contact.company_name if each_contact.company_name else '', 'last_name': each_contact.last_name if each_contact.last_name else '', 'city': each_contact.city if each_contact.city else '', 'state': each_contact.state if each_contact.state else '', 'first_name': each_contact.name, - 'email': each_contact.email, 'email_id': each_contact.id} + 'email': each_contact.email, 'email_id': each_contact.id, + 'unsubscribe_from_campaign_url': unsubscribe_from_campaign_url} html = Template(html).render(Context(names_dict)) - mail_html = html + link - from_email = str(campaign.from_name) + "<" + str(campaign.from_email) + '>' + mail_html = html + link + unsubscribe_from_campaign_html + from_email = str(campaign.from_name) + "<" + \ + str(campaign.from_email) + '>' to_email = [each_contact.email] send_campaign_mail( subject, mail_html, from_email, to_email, [], [reply_to_email], attachments) except Exception as e: - print (e) + print(e) pass @@ -159,18 +181,20 @@ def list_all_bounces_unsubscribes(): bounces = requests.get('https://api.sendgrid.com/api/bounces.get.json?api_user=' + settings.EMAIL_HOST_USER + '&api_key=' + settings.EMAIL_HOST_PASSWORD) for each in bounces.json(): - contact = Contact.objects.filter(email=each['email']).first() - if contact: - contact.is_bounced = True - contact.save() + if type(each) == dict: + contact = Contact.objects.filter(email=each.get('email')).first() + if contact: + contact.is_bounced = True + contact.save() bounces = requests.get('https://api.sendgrid.com/api/unsubscribes.get.json?api_user=' + settings.EMAIL_HOST_USER + '&api_key=' + settings.EMAIL_HOST_PASSWORD) for each in bounces.json(): - contact = Contact.objects.filter(email=each['email']).first() - if contact: - contact.is_unsubscribed = True - contact.save() + if type(each) == dict: + contact = Contact.objects.filter(email=each.get('email')).first() + if contact: + contact.is_unsubscribed = True + contact.save() @task @@ -178,16 +202,23 @@ def send_scheduled_campaigns(): from datetime import datetime campaigns = Campaign.objects.filter(schedule_date_time__isnull=False) for each in campaigns: - schedule_date_time = each.schedule_date_time + completed = CampaignCompleted.objects.filter( + is_completed=True).values_list('campaign_id', flat=True) - sent_time = datetime.now().strftime('%Y-%m-%d %H:%M') - sent_time = datetime.strptime(sent_time, '%Y-%m-%d %H:%M') - local_tz = pytz.timezone(settings.TIME_ZONE) - sent_time = local_tz.localize(sent_time) - sent_time = convert_to_custom_timezone(sent_time, each.timezone, to_utc=True) + if each.id not in completed: + schedule_date_time = each.schedule_date_time - if ( - str(each.schedule_date_time.date()) == str(sent_time.date()) and - str(schedule_date_time.hour) == str(sent_time.hour) - ): - run_campaign(each) + sent_time = datetime.now().strftime('%Y-%m-%d %H:%M') + sent_time = datetime.strptime(sent_time, '%Y-%m-%d %H:%M') + local_tz = pytz.timezone(settings.TIME_ZONE) + sent_time = local_tz.localize(sent_time) + sent_time = convert_to_custom_timezone( + sent_time, each.timezone, to_utc=True) + + if ( + str(each.schedule_date_time.date()) == str(sent_time.date()) and + str(schedule_date_time.hour) == str(sent_time.hour) + ): + run_campaign.delay(each.id) + CampaignCompleted.objects.create( + campaign=each, is_completed=True) diff --git a/marketing/templates/contact_detail.html b/marketing/templates/contact_detail.html new file mode 100644 index 00000000..7db02519 --- /dev/null +++ b/marketing/templates/contact_detail.html @@ -0,0 +1,114 @@ +{% extends 'marketing/base.html' %} +{% load staticfiles %} +{% load paginate %} +{% block content %} +{{ block.super }} +
    +
    +
    +
    +
    +
    +
    +
    +
    + Contact Detail +
    +
    +
    +
    +
    + +
    + {{contact_obj.email}} +
    +
    +
    +
    + +
    + {{contact_obj.name}} +
    +
    +
    +
    + +
    + {{contact_obj.last_name}} +
    +
    +
    +
    + +
    + {{contact_obj.city}} +
    +
    +
    +
    + +
    + {{contact_obj.state}} +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +{% endblock %} +{% block js_block %} + + + + + +{% endblock js_block %} \ No newline at end of file diff --git a/marketing/templates/html_message_campaign.html b/marketing/templates/html_message_campaign.html new file mode 100644 index 00000000..ab89d7eb --- /dev/null +++ b/marketing/templates/html_message_campaign.html @@ -0,0 +1,14 @@ +{% block body %} + +{% block content %} +{{html|safe}} + +{% endblock content %} + + +{% block unsubscribe_link %} + Unsubscribe +{% endblock unsubscribe_link %} + + +{% endblock body %} \ No newline at end of file diff --git a/marketing/tests.py b/marketing/tests.py index d1e3edeb..af74bbd4 100644 --- a/marketing/tests.py +++ b/marketing/tests.py @@ -1,6 +1,7 @@ from django.test import TestCase from django.test import Client from common.models import User +from django.shortcuts import reverse import os # from marketing.models import Tag, Document, ContactList, EmailTemplate, Contact, Campaign, CampaignLinkClick, CampaignLog, Link, CampaignOpen # from .models import * @@ -22,51 +23,30 @@ class TestMarketingModel(object): class TestTemplates(TestMarketingModel, TestCase): def test_templates(self): - url1 = '/m/' - url2 = '/m/cl/all/' - url3 = '/m/cl/edit_contact/' - url4 = '/m/cl/lists/' - url5 = '/m/cl/list/new/' - url6 = '/m/cl/list/cnew/' - # url7 = '/m/cl/list/detail/' - url8 = '/m/et/list/' - url9 = '/m/et/new/' - # url10 = '/m/et/edit/' - # url11 = '/m/et/detail/' - url12 = '/m/cm/list/' - url13 = '/m/cm/new/' - # url14 = '/m/cm/edit/' - # url15 = '/m/cm/details/' + url1 = reverse('marketing:dashboard') + url2 = reverse('marketing:contact_lists') + url4 = reverse('marketing:contacts_list') + url5 = reverse('marketing:contact_list_new') + url8 = reverse('marketing:email_template_list') + url9 = reverse('marketing:email_template_new') + url12 = reverse('marketing:campaign_list') + url13 = reverse('marketing:campaign_new') resp1 = self.client.get(url1) resp2 = self.client.get(url2) - resp3 = self.client.get(url3) resp4 = self.client.get(url4) resp5 = self.client.get(url5) - resp6 = self.client.get(url6) - # resp7 = self.client.get(url7) resp8 = self.client.get(url8) resp9 = self.client.get(url9) - # resp10 = self.client.get(url10) - # resp11 = self.client.get(url11) resp12 = self.client.get(url12) resp13 = self.client.get(url13) - # resp14 = self.client.get(url14) - # resp15 = self.client.get(url15) self.assertEqual(resp1.status_code, 200) self.assertEqual(resp2.status_code, 200) - self.assertEqual(resp3.status_code, 200) self.assertEqual(resp4.status_code, 200) self.assertEqual(resp5.status_code, 200) - self.assertEqual(resp6.status_code, 200) - # self.assertEqual(resp7.status_code, 200) self.assertEqual(resp8.status_code, 200) self.assertEqual(resp9.status_code, 200) - # self.assertEqual(resp10.status_code, 200) - # self.assertEqual(resp11.status_code, 200) self.assertEqual(resp12.status_code, 200) self.assertEqual(resp13.status_code, 200) - # self.assertEqual(resp14.status_code, 200) - # self.assertEqual(resp15.status_code, 200) @@ -84,7 +64,7 @@ class TestCreateContacts(TestMarketingModel, TestCase): fp.writelines(data) with open('marketing/test_file.csv') as fp: - response = self.client.post('/m/cl/list/cnew/', + response = self.client.post(reverse('marketing:contact_list_new'), {'name': 'sample_test', 'attachment': fp}) self.assertEqual(response.status_code, 200) @@ -99,13 +79,13 @@ class TestViewContactList(TestMarketingModel, TestCase): # self.assertTemplateUsed(response, 'marketing/lists/detail.html') def test_contact_list_pagination(self): - response = self.client.get('/m/cl/all/?page=1') + response = self.client.get(reverse('marketing:contact_lists') + '?page=1') self.assertEqual(response.status_code, 200) - response = self.client.get('/m/cl/all/?page=asdf') + response = self.client.get(reverse('marketing:contact_lists') + '?page=asdf') self.assertEqual(response.status_code, 200) - response = self.client.get('/m/cl/all/?page=') + response = self.client.get(reverse('marketing:contact_lists') + '?page=') self.assertEqual(response.status_code, 200) @@ -113,13 +93,13 @@ class TestEmailTemplateList(TestMarketingModel, TestCase): def test_email_template_list_pagination(self): - response = self.client.get('/m/et/list/?page=1') + response = self.client.get(reverse('marketing:email_template_list') + '?page=1') self.assertEqual(response.status_code, 200) - response = self.client.get('/m/et/list/?page=asdf') + response = self.client.get(reverse('marketing:email_template_list') + '?page=asdf') self.assertEqual(response.status_code, 200) - response = self.client.get('/m/et/list/?page=') + response = self.client.get(reverse('marketing:email_template_list') + '?page=') self.assertEqual(response.status_code, 200) diff --git a/marketing/urls.py b/marketing/urls.py index 8da6f9e0..9679fa7a 100644 --- a/marketing/urls.py +++ b/marketing/urls.py @@ -4,7 +4,8 @@ from .views import ( email_template_list, email_template_new, email_template_edit, email_template_delete, email_template_detail, campaign_list, campaign_new, campaign_edit, campaign_details, campaign_delete, edit_contact_list, delete_contact_list, failed_contact_list_detail, failed_contact_list_download_delete, - campaign_link_click, campaign_open + campaign_link_click, campaign_open, demo_file_download, delete_contact, unsubscribe_from_campaign, contact_detail, + edit_failed_contact, delete_failed_contact, download_contacts_for_campaign, create_campaign_from_template ) app_name = 'marketing' @@ -12,29 +13,37 @@ app_name = 'marketing' urlpatterns = [ path('', dashboard, name='dashboard'), - path('cl/all/', contact_lists, name='contact_lists'), - path('cl/edit_contact/', edit_contact, name='edit_contact'), - path('cl/lists/', contacts_list, name='contacts_list'), - path('cl/list/new/', contact_list_new, name='contact_list_new'), + path('contact-list/', contact_lists, name='contact_lists'), + path('contacts//edit/', edit_contact, name='edit_contact'), + path('contacts//delete/', delete_contact, name='delete_contact'), + path('contacts/', contacts_list, name='contacts_list'), + path('contact-list/create/', contact_list_new, name='contact_list_new'), path('cl/list/cnew/', contacts_list_new, name='contacts_list_new'), - path('cl/list//detail/', contact_list_detail, name='contact_list_detail'), - path('cl/list//failed/', failed_contact_list_detail, name='failed_contact_list_detail'), - path('cl/list//failed/download/', + path('contact-list//detail/', contact_list_detail, name='contact_list_detail'), + path('contact-list//failed/', failed_contact_list_detail, name='failed_contact_list_detail'), + path('contact-list//failed/edit/', edit_failed_contact, name='edit_failed_contact'), + path('contact-list//failed/delete/', delete_failed_contact, name='delete_failed_contact'), + path('contact-list//failed/download/', failed_contact_list_download_delete, name='failed_contact_list_download_delete'), - path('cl/list//edit/', edit_contact_list, name='edit_contact_list'), + path('contact-list//edit/', edit_contact_list, name='edit_contact_list'), path('cl/list//delete/', delete_contact_list, name='delete_contact_list'), - path('et/list/', email_template_list, name='email_template_list'), - path('et/new/', email_template_new, name='email_template_new'), - path('et//edit/', email_template_edit, name='email_template_edit'), - path('et//detail/', email_template_detail, name='email_template_detail'), - path('et//delete/', email_template_delete, name='email_template_delete'), + path('email-templates/', email_template_list, name='email_template_list'), + path('email-templates/create/', email_template_new, name='email_template_new'), + path('email-templates//edit/', email_template_edit, name='email_template_edit'), + path('email-templates//detail/', email_template_detail, name='email_template_detail'), + path('email-templates//delete/', email_template_delete, name='email_template_delete'), - path('cm/list/', campaign_list, name='campaign_list'), - path('cm/new/', campaign_new, name='campaign_new'), + path('campaigns/', campaign_list, name='campaign_list'), + path('campaigns/create/', campaign_new, name='campaign_new'), path('cm//edit/', campaign_edit, name='campaign_edit'), - path('cm//details/', campaign_details, name='campaign_details'), - path('cm//delete/', campaign_delete, name='campaign_delete'), + path('campaigns//details/', campaign_details, name='campaign_details'), + path('campaigns//delete/', campaign_delete, name='campaign_delete'), path('cm/link//e//', campaign_link_click, name='campaign_link_click'), path('cm/track-email//contact//', campaign_open, name='campaign_open'), + path('demo-file-download-for-contacts-list/', demo_file_download, name='demo_file_download'), + path('unsubscribe-from-campaign//', unsubscribe_from_campaign, name="unsubscribe_from_campaign"), + path('contacts//view/', contact_detail, name="contact_detail"), + path('download-contacts-for-campaign//', download_contacts_for_campaign, name="download_contacts_for_campaign"), + path('create-campaign-from-template//', create_campaign_from_template, name="create_campaign_from_template"), ] diff --git a/marketing/views.py b/marketing/views.py index 494fc906..17ed3bbe 100644 --- a/marketing/views.py +++ b/marketing/views.py @@ -1,27 +1,30 @@ import csv import json -import pytz +from datetime import datetime as ddatetime + import lxml import lxml.html -from lxml.cssselect import CSSSelector -from datetime import datetime as ddatetime +import pytz from django.conf import settings from django.contrib.auth.decorators import login_required -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.exceptions import PermissionDenied +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q -from django.http.response import (JsonResponse, - HttpResponse, HttpResponseRedirect) -from django.shortcuts import render, get_object_or_404, redirect -from django.urls import reverse_lazy, reverse +from django.http.response import (HttpResponse, HttpResponseRedirect, + JsonResponse) +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse, reverse_lazy +from lxml.cssselect import CSSSelector + from common import status from common.utils import convert_to_custom_timezone -from marketing.models import ( - Tag, ContactList, Contact, EmailTemplate, Campaign, CampaignLog, Link, CampaignLinkClick, - CampaignOpen -) -from marketing.forms import ContactListForm, ContactForm, EmailTemplateForm, SendCampaignForm -from marketing.tasks import upload_csv_file, run_campaign - +from common.models import User +from marketing.forms import (ContactForm, ContactListForm, EmailTemplateForm, + SendCampaignForm) +from marketing.models import (Campaign, CampaignLinkClick, CampaignLog, + CampaignOpen, Contact, ContactList, + EmailTemplate, Link, Tag, FailedContact) +from marketing.tasks import run_campaign, upload_csv_file TIMEZONE_CHOICES = [(tz, tz) for tz in pytz.common_timezones] @@ -37,51 +40,85 @@ def get_exact_match(query, m2m_field, ids): @login_required(login_url='/login') def dashboard(request): - return render(request, 'marketing/dashboard.html') + if request.user.role == 'ADMIN' or request.user.is_superuser: + email_templates = EmailTemplate.objects.all() + contacts = Contact.objects.all() + campaign = Campaign.objects.all() + contacts_list = ContactList.objects.all() + else: + email_templates = EmailTemplate.objects.filter(created_by=request.user) + contacts = Contact.objects.filter(created_by=request.user) + campaign = Campaign.objects.filter(created_by=request.user) + contacts_list = ContactList.objects.filter(created_by=request.user) + + context = { + 'email_templates': email_templates, + 'contacts': contacts, + 'campaigns': campaign, + 'contacts_list': contacts_list + } + return render(request, 'marketing/dashboard.html', context) @login_required(login_url='/login') def contact_lists(request): tags = Tag.objects.all() + users = User.objects.all() if (request.user.role == "ADMIN"): queryset = ContactList.objects.all() else: - queryset = ContactList.objects.filter(created_by=request.user) + queryset = ContactList.objects.filter( + Q(created_by=request.user) | Q(visible_to=request.user)) + # users = User.objects.none() + if request.GET.get('tag'): + queryset = queryset.filter(tags=request.GET.get('tag')) if request.method == 'POST': - post_tags = request.POST.get('tags') - if request.POST.get('search'): + post_tags = request.POST.getlist('tag') + + if request.POST.get('contact_list_name'): queryset = queryset.filter( - Q(name__icontains=request.POST['search']) | - Q(created_by__email__icontains=request.POST[ - 'search'])).distinct() + name__icontains=request.POST.get('contact_list_name')) - if post_tags: + if request.POST.get('created_by'): + queryset = queryset.filter( + created_by=request.POST.get('created_by')) - filtered_list = json.loads(request.POST['tags']) + if request.POST.get('tag'): + queryset = queryset.filter(tags__id__in=post_tags) + request_tags = request.POST.getlist('tag') - if len(filtered_list) > 1: - queryset = get_exact_match(queryset, 'tags', filtered_list) - else: - queryset = queryset.filter(tags__id__in=filtered_list) - - per_page = request.GET.get("per_page", 10) - paginator = Paginator(queryset, per_page) - page = request.GET.get('page', 1) - try: - contact_lists = paginator.page(page) - except PageNotAnInteger: - contact_lists = paginator.page(1) - except EmptyPage: - contact_lists = paginator.page(paginator.num_pages) - data = {'contact_lists': contact_lists, 'tags': tags} + data = {'contact_lists': queryset, 'tags': tags, 'users': users, + 'request_tags': request.POST.getlist('tag'), } return render(request, 'marketing/lists/index.html', data) @login_required(login_url='/login') def contacts_list(request): + users = User.objects.all() + if (request.user.role == "ADMIN"): + contacts = Contact.objects.all() + else: + contacts = Contact.objects.filter(created_by=request.user) + # users = User.objects.none() contacts = Contact.objects.all() - data = {"contacts": contacts} - return render(request, 'marketing/lists/all.html', data) + if request.method == 'GET': + context = {'contacts': contacts, 'users': users} + return render(request, 'marketing/lists/all.html', context) + + if request.method == 'POST': + data = request.POST + if data.get('contact_name'): + contacts = contacts.filter( + name__icontains=data.get('contact_name')) + + if data.get('contact_email'): + contacts = contacts.filter(email=data.get('contact_email')) + + if data.get('created_by'): + contacts = contacts.filter(created_by=data.get('created_by')) + + context = {'contacts': contacts, 'users': User.objects.all()} + return render(request, 'marketing/lists/all.html', context) @login_required(login_url='/login') @@ -182,11 +219,12 @@ def view_contact_list(request, pk): @login_required(login_url='/login') def delete_contact_list(request, pk): - try: - # ContactList.objects.get(pk=pk).delete() - redirect_to = reverse('marketing:contact_lists') - except ContactList.DoesNotExist: - redirect_to = reverse('marketing:contact_lists') + contact_list_obj = get_object_or_404(ContactList, pk=pk) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or contact_list_obj.created_by == request.user): + raise PermissionDenied + + contact_list_obj.delete() + redirect_to = reverse('marketing:contact_lists') return HttpResponseRedirect(redirect_to) @@ -214,14 +252,47 @@ def contacts_list_new(request): @login_required(login_url='/login') -def edit_contact(request): - return render(request, 'marketing/lists/edit_contact.html') +def edit_contact(request, pk): + contact_obj = get_object_or_404(Contact, pk=pk) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or contact_obj.created_by == request.user): + raise PermissionDenied + if request.method == 'GET': + form = ContactForm(instance=contact_obj) + return render(request, 'marketing/lists/edit_contact.html', {'form': form}) + + if request.method == 'POST': + form = ContactForm(request.POST, instance=contact_obj) + if form.is_valid(): + contact = form.save(commit=False) + contact.save() + form.save_m2m() + return JsonResponse({'error': False, 'success_url': reverse('marketing:contacts_list')}) + else: + return JsonResponse({'error': True, 'errors': form.errors, }) + + +@login_required(login_url='/login') +def delete_contact(request, pk): + contact_obj = get_object_or_404(Contact, pk=pk) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or contact_obj.created_by == request.user): + raise PermissionDenied + contact_obj.delete() + return redirect('marketing:contacts_list') @login_required(login_url='/login') def contact_list_detail(request, pk): contact_list = get_object_or_404(ContactList, pk=pk) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or contact_list.created_by == request.user): + raise PermissionDenied contacts_list = contact_list.contacts.all() + if request.POST: + if request.POST.get('name'): + contacts_list = contacts_list.filter(name__icontains=request.POST.get('name')) + if request.POST.get('email'): + contacts_list = contacts_list.filter(email=request.POST.get('email')) + if request.POST.get('company_name'): + contacts_list = contacts_list.filter(company_name=request.POST.get('company_name')) data = {'contact_list': contact_list, "contacts_list": contacts_list} return render(request, 'marketing/lists/detail.html', data) @@ -230,7 +301,8 @@ def contact_list_detail(request, pk): def failed_contact_list_detail(request, pk): contact_list = get_object_or_404(ContactList, pk=pk) failed_contacts_list = contact_list.failed_contacts.all() - data = {'contact_list': contact_list, "failed_contacts_list": failed_contacts_list} + data = {'contact_list': contact_list, + "failed_contacts_list": failed_contacts_list} return render(request, 'marketing/lists/failed_detail.html', data) @@ -242,7 +314,8 @@ def failed_contact_list_download_delete(request, pk): response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="failed_contacts.csv"' writer = csv.writer(response) - writer.writerow(["company name", "email", "first name", "last name", "city", "state"]) + writer.writerow(["company name", "email", "first name", + "last name", "city", "state"]) for contact in failed_contacts_list: writer.writerow([ @@ -255,27 +328,23 @@ def failed_contact_list_download_delete(request, pk): @login_required(login_url='/login') def email_template_list(request): + users = User.objects.all() if (request.user.is_admin or request.user.is_superuser): queryset = EmailTemplate.objects.all() else: queryset = EmailTemplate.objects.filter( created_by=request.user) + # users = User.objects.none() if request.method == 'POST': - queryset = queryset.filter( - Q(title__icontains=request.POST['search']) | - Q(subject__icontains=request.POST['search']) | - Q(created_by__email__icontains=request.POST['search'])) + if request.POST.get('template_name'): + queryset = queryset.filter( + title__icontains=request.POST.get('template_name')) - per_page = request.GET.get("per_page", 10) - paginator = Paginator(queryset.distinct(), per_page) - page = request.GET.get('page') - try: - email_templates = paginator.page(page) - except PageNotAnInteger: - email_templates = paginator.page(1) - except EmptyPage: - email_templates = paginator.page(paginator.num_pages) - data = {'email_templates': email_templates} + if request.POST.get('created_by'): + queryset = queryset.filter( + created_by=request.POST.get('created_by')) + + data = {'email_templates': queryset, 'users': users} return render(request, 'marketing/email_template/index.html', data) @@ -321,7 +390,7 @@ def email_template_detail(request, pk): @login_required(login_url='/login') def email_template_delete(request, pk): try: - # EmailTemplate.objects.get(id=pk).delete() + EmailTemplate.objects.get(id=pk).delete() redirect_to = reverse('marketing:email_template_list') except ContactList.DoesNotExist: redirect_to = reverse('marketing:email_template_list') @@ -330,25 +399,24 @@ def email_template_delete(request, pk): @login_required(login_url='/login') def campaign_list(request): - campaigns = Campaign.objects.all() + users = User.objects.all() + if (request.user.role == "ADMIN"): + queryset = Campaign.objects.all() + else: + queryset = Campaign.objects.all().filter(created_by=request.user) + # users = User.objects.none() + if request.GET.get('tag'): + queryset = queryset.filter(tags=request.GET.get('tag')) if request.method == 'POST': - campaigns = campaigns.filter( - Q(title__icontains=request.POST['search']) | - Q(created_by__email__icontains=request.POST['search']) - ) - if request.POST['scheduled_on']: - campaigns = campaigns.filter( - schedule_date_time__date__lte=request.POST['scheduled_on']) - per_page = request.GET.get("per_page", 10) # Show 15 contacts per page - paginator = Paginator(campaigns.distinct().order_by('-created_on'), per_page) - page = request.GET.get('page') - try: - campaigns = paginator.page(page) - except PageNotAnInteger: - campaigns = paginator.page(1) - except EmptyPage: - campaigns = paginator.page(paginator.num_pages) - data = {'campaigns_list': campaigns} + if request.POST.get('campaign_name'): + queryset = queryset.filter( + title__icontains=request.POST.get('campaign_name')) + + if request.POST.get('created_by'): + queryset = queryset.filter( + created_by=request.POST.get('created_by')) + + data = {'campaigns_list': queryset, 'users': users} return render(request, 'marketing/campaign/index.html', data) @@ -375,7 +443,8 @@ def campaign_new(request): # return JsonResponse(data, status=status.HTTP_200_OK) return render(request, 'marketing/campaign/new.html', data) else: - form = SendCampaignForm(request.POST, request.FILES) + form = SendCampaignForm(request.POST, request.FILES, + contacts_list=request.POST.getlist('contact_list')) if form.is_valid(): instance = form.save(commit=False) instance.created_by = request.user @@ -393,6 +462,12 @@ def campaign_new(request): # contacts = Contact.objects.filter(contact_list__in=json.loads(request.POST['contact_list'])).distinct() # for each_contact in contacts: # instance.contacts.add(each_contact) + tags = request.POST['tags'].split( + ',') if request.POST['tags'] else [] + for each in tags: + tag, _ = Tag.objects.get_or_create( + name=each, created_by=request.user) + instance.tags.add(tag) camp = instance @@ -425,13 +500,16 @@ def campaign_new(request): schedule_date_time = request.POST.get('schedule_date_time', '') user_timezone = request.POST.get('timezone', '') if user_timezone and schedule_date_time: - start_time_object = ddatetime.strptime(schedule_date_time, '%Y-%m-%d %H:%M') - schedule_date_time = convert_to_custom_timezone(start_time_object, user_timezone, to_utc=True) + start_time_object = ddatetime.strptime( + schedule_date_time, '%Y-%m-%d %H:%M') + schedule_date_time = convert_to_custom_timezone( + start_time_object, user_timezone, to_utc=True) instance.schedule_date_time = schedule_date_time instance.timezone = user_timezone instance.save() else: - run_campaign.delay(instance.id) + run_campaign.delay( + instance.id, domain=request.get_host(), protocol=request.scheme) return JsonResponse({'error': False, 'data': form.data}, status=status.HTTP_201_CREATED) return JsonResponse({'error': True, 'errors': form.errors}, status=status.HTTP_200_OK) @@ -463,11 +541,13 @@ def campaign_details(request, pk): contacts = Contact.objects.filter(contact_list__id__in=contact_list_ids) all_contacts = contacts - bounced_contacts = contacts.filter(is_bounced=True) + bounced_contacts = contacts.filter(is_bounced=True).distinct() unsubscribe_contacts = contacts.filter( - is_unsubscribed=True) - read_contacts = campaign.marketing_links.filter(Q(clicks__gt=0)) - # read_contacts = + is_unsubscribed=True).distinct() + # read_contacts = campaign.marketing_links.filter(Q(clicks__gt=0)).distinct() + contact_ids = CampaignOpen.objects.filter( + campaign=campaign).values_list('contact_id', flat=True) + read_contacts = Contact.objects.filter(id__in=contact_ids) sent_contacts = contacts.exclude( Q(is_bounced=True) | Q(is_unsubscribed=True)) if request.POST: @@ -489,7 +569,8 @@ def campaign_details(request, pk): except EmptyPage: contacts = contacts_paginator.page(contacts_paginator.num_pages) - unsubscribe_contacts_paginator = Paginator(unsubscribe_contacts.order_by('id'), per_page) + unsubscribe_contacts_paginator = Paginator( + unsubscribe_contacts.order_by('id'), per_page) if (tab == "unsubscribe_contacts"): page = page else: @@ -500,9 +581,11 @@ def campaign_details(request, pk): except PageNotAnInteger: unsubscribe_contacts = unsubscribe_contacts_paginator.page(1) except EmptyPage: - unsubscribe_contacts = unsubscribe_contacts_paginator.page(unsubscribe_contacts_paginator.num_pages) + unsubscribe_contacts = unsubscribe_contacts_paginator.page( + unsubscribe_contacts_paginator.num_pages) - bounced_contacts_paginator = Paginator(bounced_contacts.order_by('id'), per_page) + bounced_contacts_paginator = Paginator( + bounced_contacts.order_by('id'), per_page) if (tab == "bounced_contacts"): page = page else: @@ -513,7 +596,8 @@ def campaign_details(request, pk): except PageNotAnInteger: bounced_contacts = bounced_contacts_paginator.page(1) except EmptyPage: - bounced_contacts = bounced_contacts_paginator.page(bounced_contacts_paginator.num_pages) + bounced_contacts = bounced_contacts_paginator.page( + bounced_contacts_paginator.num_pages) sent_contacts_paginator = Paginator(sent_contacts.order_by('id'), per_page) if (tab == "sent_contacts"): @@ -526,7 +610,8 @@ def campaign_details(request, pk): except PageNotAnInteger: sent_contacts = sent_contacts_paginator.page(1) except EmptyPage: - sent_contacts = sent_contacts_paginator.page(sent_contacts_paginator.num_pages) + sent_contacts = sent_contacts_paginator.page( + sent_contacts_paginator.num_pages) all_contacts_paginator = Paginator(all_contacts.order_by('id'), per_page) if (tab == "all_contacts"): @@ -539,7 +624,8 @@ def campaign_details(request, pk): except PageNotAnInteger: all_contacts = all_contacts_paginator.page(1) except EmptyPage: - all_contacts = all_contacts_paginator.page(all_contacts_paginator.num_pages) + all_contacts = all_contacts_paginator.page( + all_contacts_paginator.num_pages) read_contacts_paginator = Paginator(read_contacts.order_by('id'), per_page) if (tab == "read_contacts"): @@ -552,7 +638,8 @@ def campaign_details(request, pk): except PageNotAnInteger: read_contacts = read_contacts_paginator.page(1) except EmptyPage: - read_contacts = read_contacts_paginator.page(read_contacts_paginator.num_pages) + read_contacts = read_contacts_paginator.page( + read_contacts_paginator.num_pages) data = {'contact_lists': contact_lists, "campaign": campaign, "contacts": contacts, 'unsubscribe_contacts': unsubscribe_contacts, @@ -611,8 +698,7 @@ def campaign_open(request, campaign_log_id, email_id): campaign_log = CampaignLog.objects.filter(id=campaign_log_id).first() contact = Contact.objects.filter(id=email_id).first() - image_data = settings.STATIC_URL + 'images/' - image_data = open(settings.STATIC_ROOT + '/images/company.png', 'rb').read() + image_data = open('static/images/company.png', 'rb').read() if campaign_log and contact: campaign_open = CampaignOpen.objects.filter( campaign=campaign_log.campaign, contact=contact).first() @@ -627,3 +713,111 @@ def campaign_open(request, campaign_log_id, email_id): campaign_log.campaign.save() campaign_open.save() return HttpResponse(image_data, content_type="image/png") + + +def demo_file_download(request): + sample_data = [ + 'company name,email,first name,last name,city,state\n', + 'company_name_1,user1@email.com,first_name,last_name,Hyderabad,Telangana\n', + 'company_name_2,user2@email.com,first_name,last_name,Hyderabad,Telangana\n', + 'company_name_3,user3@email.com,first_name,last_name,Hyderabad,Telangana\n', + 'company_name_4,user4@email.com,first_name,last_name,Hyderabad,Telangana\n' + ] + response = HttpResponse( + sample_data, content_type='text/plain') + response['Content-Disposition'] = 'attachment; filename={}'.format( + 'sample_data.csv') + return response + + +def unsubscribe_from_campaign(request, contact_id): + contact_obj = get_object_or_404(Contact, pk=contact_id) + contact_obj.is_unsubscribed = True + contact_obj.save() + return HttpResponseRedirect('/') + + +@login_required +def contact_detail(request, contact_id): + contact_obj = get_object_or_404(Contact, pk=contact_id) + + if not (request.user.role == 'ADMIN' or request.user.is_superuser or contact_obj.created_by == request.user): + raise PermissionDenied + if request.method == 'GET': + return render(request, 'contact_detail.html', {'contact_obj': contact_obj}) + + +@login_required(login_url='/login') +def edit_failed_contact(request, pk): + contact_obj = get_object_or_404(FailedContact, pk=pk) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or contact_obj.created_by == request.user): + raise PermissionDenied + if request.method == 'GET': + form = ContactForm(instance=contact_obj) + return render(request, 'marketing/lists/edit_contact.html', {'form': form}) + + if request.method == 'POST': + form = ContactForm(request.POST, instance=contact_obj) + if form.is_valid(): + contact = form.save(commit=False) + contact.save() + form.save_m2m() + return JsonResponse({'error': False, 'success_url': reverse('marketing:contacts_list')}) + else: + return JsonResponse({'error': True, 'errors': form.errors, }) + + +@login_required(login_url='/login') +def delete_failed_contact(request, pk): + contact_obj = get_object_or_404(FailedContact, pk=pk) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or contact_obj.created_by == request.user): + raise PermissionDenied + if request.method == 'GET': + contact_obj.delete() + return redirect('marketing:contacts_list') + + +@login_required +def download_contacts_for_campaign(request, compaign_id): + campaign_obj = get_object_or_404(Campaign, pk=compaign_id) + if not (request.user.role == 'ADMIN' or request.user.is_superuser or campaign_obj.created_by == request.user): + raise PermissionDenied + if request.method == 'GET': + if request.GET.get('is_bounced') == 'true': + contact_ids = campaign_obj.contact_lists.filter(contacts__is_bounced=True).values_list( + 'contacts__id', flat=True) + contacts = Contact.objects.filter(id__in=contact_ids).values( + 'company_name', 'email', 'name', 'last_name', 'city', 'state') + + if request.GET.get('is_unsubscribed') == 'true': + contact_ids = campaign_obj.contact_lists.filter(contacts__is_unsubscribed=True).values_list( + 'contacts__id', flat=True) + contacts = Contact.objects.filter(id__in=contact_ids).values( + 'company_name', 'email', 'name', 'last_name', 'city', 'state') + + if request.GET.get('is_opened') == 'true': + contact_ids = CampaignOpen.objects.filter( + campaign=campaign_obj).values_list('contact_id', flat=True) + contacts = Contact.objects.filter(id__in=contact_ids).values( + 'company_name', 'email', 'name', 'last_name', 'city', 'state') + + data = [ + 'company name,email,first name,last name,city,state\n', + ] + for contact in contacts: + data.append((', '.join(contact.values()) + '\n')) + response = HttpResponse( + data, content_type='text/plain') + response['Content-Disposition'] = 'attachment; filename={}'.format( + 'data_downloads.csv') + return response + + +@login_required +def create_campaign_from_template(request, template_id): + email_template_obj = get_object_or_404(EmailTemplate, pk=template_id) + if request.method == 'GET': + url_location = reverse('marketing:campaign_new') + url_query_params = '?email_template={}'.format(template_id) + url = url_location + url_query_params + return HttpResponseRedirect(url) diff --git a/opportunity/forms.py b/opportunity/forms.py index 5a9e1d60..c8ef7c0d 100644 --- a/opportunity/forms.py +++ b/opportunity/forms.py @@ -15,10 +15,11 @@ class OpportunityForm(forms.ModelForm): field.widget.attrs = {"class": "form-control"} self.fields['description'].widget.attrs.update({ 'rows': '8'}) - self.fields['assigned_to'].queryset = assigned_users + if assigned_users: + self.fields['assigned_to'].queryset = assigned_users + self.fields['assigned_to'].required = False self.fields['account'].queryset = opp_accounts self.fields['contacts'].queryset = opp_contacts - self.fields['assigned_to'].required = False self.fields['contacts'].required = False for key, value in self.fields.items(): value.widget.attrs['placeholder'] = value.label diff --git a/opportunity/tasks.py b/opportunity/tasks.py new file mode 100644 index 00000000..d3fb01d3 --- /dev/null +++ b/opportunity/tasks.py @@ -0,0 +1,36 @@ +from celery.task import task +from django.conf import settings +from django.core.mail import EmailMultiAlternatives, EmailMessage +from django.db.models import Q +from django.shortcuts import reverse +from django.template.loader import render_to_string + +from accounts.models import User +from opportunity.models import Opportunity + + +@task +def send_email_to_assigned_user(recipients, opportunity_id, domain='demo.django-crm.io', protocol='http'): + """ Send Mail To Users When they are assigned to a opportunity """ + opportunity = Opportunity.objects.get(id=opportunity_id) + for user in recipients: + recipients_list = [] + user = User.objects.filter(id=user).first() + if user: + recipients_list.append(user.email) + context = {} + context["url"] = protocol + '://' + domain + \ + reverse('opportunity:opp_view', args=(opportunity.id,)) + context["user"] = user + context["opportunity"] = opportunity + subject = 'Assigned to opportunity.' + html_content = render_to_string( + 'assigned_to/opportunity_assigned.html', context=context) + + msg = EmailMessage( + subject, + html_content, + to=recipients_list + ) + msg.content_subtype = "html" + msg.send() diff --git a/opportunity/templates/opportunity.html b/opportunity/templates/opportunity.html index 98fc252c..0253cbe3 100644 --- a/opportunity/templates/opportunity.html +++ b/opportunity/templates/opportunity.html @@ -1,13 +1,17 @@ {% extends "sales/base.html" %} {% load staticfiles %} {% load paginate %} +{% block extralinks %} + +{% endblock %} {% block content %}
    @@ -15,83 +19,103 @@
    -
    -
    -
    Filters
    -
    -
    -
    - - + +
    +
    Filters
    +
    +
    +
    + + +
    -
    -
    -
    - - +
    +
    + + +
    -
    -
    -
    - - +
    +
    + + +
    -
    -
    -
    - - +
    +
    + + +
    -
    -
    -
    - - Clear +
    +
    + + +
    +
    +
    +
    + + Clear +
    -
    - +
    -
    +
    - +
    -
    - Opportunities - {% if show_pageitems %} {% show_pageitems %}{% else %} {{ opportunity_list|length }}{% endif %} - - - - -
    -
    - - - {% if opportunity_list|length > 0 %} +
    + Opportunities - {% if show_pageitems %} {% show_pageitems %}{% else %} + {{ opportunity_list|length }}{% endif %} + + + + +
    +
    +
    + + {% if opportunity_list|length > 0 %} - + @@ -99,48 +123,48 @@ - {% endif %} - - - {% if per_page %} + {% endif %} + + + {% if per_page %} {% paginate per_page opportunity_list %} - {% else %} + {% else %} {% paginate 10 opportunity_list %} - {% endif %} - {% for opportunity in opportunity_list %} - - - - - - + + + + + - - - - - - {% endfor %} - -
    S.No NameAssigned UserAssigned To Tags Account Lead Source Actions
    {{ forloop.counter }}{{ opportunity.name }} - {% with opportunity_users=opportunity.assigned_to.all %} - {% if opportunity_users %} - {% for user in opportunity_users %} - {{ user.email }}
    - {% endfor %} - {% else %} - Not Assigned to Any - {% endif %} - {% endwith %} -
    - {% with tags=opportunity.tags.all %} - {% if tags %} - {% for tag in tags %} - {{ tag.name }} - {% endfor %} - {% else %} - No Tags - {% endif %} - {% endwith %} - - {% if opportunity.account %} + {% endif %} + {% for opportunity in opportunity_list %} +
    {{ forloop.counter }}{{ opportunity.name }} + {% with opportunity_users=opportunity.assigned_to.all %} + {% if opportunity_users %} + {% for user in opportunity_users %} + {{ user.email }}
    + {% endfor %} + {% else %} + None + {% endif %} + {% endwith %} +
    + {% with tags=opportunity.tags.all %} + {% if tags %} + {% for tag in tags %} + {{ tag.name }} + {% endfor %} + {% else %} + No Tags + {% endif %} + {% endwith %} + + {% if opportunity.account %} {{ opportunity.account }} - {% else %} + {% else %} No Related Account - {% endif %} - {% if opportunity.stage %}{{ opportunity.stage }}{% else %}Not Specified {% endif %}{% if opportunity.lead_source %}{{ opportunity.lead_source }}{% else %}Not Specified{% endif %} - - - {% if request.user == opportunity.created_by or request.user.role == "ADMIN" or request.user.is_superuser %} - - {% endif %} -
    + {% if opportunity.stage %}{{ opportunity.stage }}{% else %}Not Specified {% endif %} + {% if opportunity.lead_source %}{{ opportunity.lead_source }}{% else %}Not Specified{% endif %} + + + + + {% if request.user == opportunity.created_by or request.user.role == "ADMIN" or request.user.is_superuser %} + + {% endif %} + + + {% endfor %} + +
    {% ifequal opportunity_list|length 0 %} -
    No Opportunity Records Found
    +
    No Opportunity Records Found
    {% endifequal %}
    {% show_pages %} @@ -178,25 +205,26 @@
    - +
    {% endblock %} {% block js_block %} -{% endblock js_block %} +{% endblock js_block %} \ No newline at end of file diff --git a/opportunity/templates/view_opportunity.html b/opportunity/templates/view_opportunity.html index 9a7008c7..1f763c0a 100644 --- a/opportunity/templates/view_opportunity.html +++ b/opportunity/templates/view_opportunity.html @@ -1,6 +1,15 @@ {% extends 'sales/base.html' %} {% load staticfiles %} +{% load thumbnail %} {% block breadcrumb %} +{% block extralinks %} + + +{% endblock extralinks %}
    - {% endif %} + {% endif %}
    - Created by {{ opportunity_record.created_by }} created on {{ opportunity_record.created_on }} + Created by {{ opportunity_record.created_by }} created on + {{ opportunity_record.created_on }}
    @@ -140,10 +158,13 @@
    -
    +
    + +

    - +
    @@ -154,17 +175,25 @@ {% if request.user.is_superuser or request.user.role == 'ADMIN' or request.user == attachment.created_by %}
    - +
    {% endif %}
    + {% if attachment.attachment %} + {% if 'image' in attachment.file_type %} + {% thumbnail attachment.attachment "80x80" crop="center" as im %} + + {% endthumbnail %} + {% endif %} + {% endif %}
      {{attachment.file_name}} Download
    -
     Uploaded by : {{ attachment.created_by }}
    +
     Uploaded by : {{ attachment.created_by }}
    {{ attachment.created_on|date:'m/d/Y,  h:i A' }}
    @@ -183,11 +212,15 @@
    - -
    + +
    + +

    - +
    @@ -198,7 +231,8 @@ {% if request.user.is_superuser or request.user.role == 'ADMIN' or request.user == comment.commented_by %}
    - +
    - @@ -257,26 +302,28 @@ {% block js_block %} - - + + + {% endblock js_block %} \ No newline at end of file diff --git a/opportunity/tests.py b/opportunity/tests.py index 5ad10263..1c837a7c 100644 --- a/opportunity/tests.py +++ b/opportunity/tests.py @@ -116,11 +116,11 @@ class opportunityCreateTestCase(OpportunityModel, TestCase): def test_del_opportunity_url(self): response = self.client.get( '/opportunities/' + str(self.opportunity.id) + '/delete/') - self.assertEqual(response['location'], '/opportunities/list/') + self.assertEqual(response['location'], '/opportunities/') def test_opportunity_delete(self): Opportunity.objects.filter(id=self.account.id).delete() - response = self.client.get('/opportunities/list/') + response = self.client.get(reverse('opportunity:list')) self.assertEqual(response.status_code, 200) @@ -172,7 +172,7 @@ class OpportunityListView(OpportunityModel, TestCase): def test_opportunity_list(self): self.opportunity = Opportunity.objects.all() - response = self.client.get('/opportunities/list/') + response = self.client.get(reverse('opportunity:list')) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'opportunity.html') @@ -181,7 +181,7 @@ class OpportunityListView(OpportunityModel, TestCase): data = { 'name': 'meghan', 'stage': 'city', 'lead_source': 'Call', 'accounts': self.account} - response = self.client.post('/opportunities/list/', data) + response = self.client.post(reverse('opportunity:list'), data) get_opp_val = Opportunity.objects.get(lead_source='Call') self.assertEqual(get_opp_val.lead_source, 'Call') self.assertEqual(get_opp_val.name, str(get_opp_val)) diff --git a/opportunity/urls.py b/opportunity/urls.py index de3931b4..83940957 100644 --- a/opportunity/urls.py +++ b/opportunity/urls.py @@ -12,7 +12,7 @@ app_name = 'opportunity' urlpatterns = [ - path('list/', OpportunityListView.as_view(), name='list'), + path('', OpportunityListView.as_view(), name='list'), path('create/', create_opportunity, name='save'), path('/view/', OpportunityDetailView.as_view(), name="opp_view"), path('/edit/', update_opportunity, name="opp_edit"), diff --git a/opportunity/views.py b/opportunity/views.py index 02a75aba..ece5fc10 100644 --- a/opportunity/views.py +++ b/opportunity/views.py @@ -17,6 +17,8 @@ from opportunity.models import Opportunity from django.urls import reverse from django.db.models import Q from django.core.exceptions import PermissionDenied +from common.tasks import send_email_user_mentions +from opportunity.tasks import send_email_to_assigned_user class OpportunityListView(LoginRequiredMixin, TemplateView): @@ -33,6 +35,9 @@ class OpportunityListView(LoginRequiredMixin, TemplateView): Q(assigned_to__in=[self.request.user]) | Q(created_by=self.request.user.id)) + if self.request.GET.get('tag', None): + queryset = queryset.filter(tags__in = self.request.GET.getlist('tag')) + request_post = self.request.POST if request_post: if request_post.get('name'): @@ -49,7 +54,9 @@ class OpportunityListView(LoginRequiredMixin, TemplateView): if request_post.get('contacts'): queryset = queryset.filter( contacts=request_post.get('contacts')) - return queryset + if request_post.get('tag'): + queryset = queryset.filter(tags__in=request_post.getlist('tag')) + return queryset.distinct() def get_context_data(self, **kwargs): context = super(OpportunityListView, self).get_context_data(**kwargs) @@ -59,7 +66,14 @@ class OpportunityListView(LoginRequiredMixin, TemplateView): context["stages"] = STAGES context["sources"] = SOURCES context["per_page"] = self.request.POST.get('per_page') - + tag_ids = list(set(Opportunity.objects.values_list('tags', flat=True))) + context["tags"] = Tags.objects.filter(id__in=tag_ids) + if self.request.POST.get('tag', None): + context["request_tags"] = self.request.POST.getlist('tag') + elif self.request.GET.get('tag', None): + context["request_tags"] = self.request.GET.getlist('tag') + else: + context["request_tags"] = None search = False if ( self.request.POST.get('name') or self.request.POST.get('stage') or @@ -85,9 +99,15 @@ def create_opportunity(request): created_by=request.user) contacts = Contact.objects.filter( Q(assigned_to__in=[request.user]) | Q(created_by=request.user)) - + users = [] + if request.user.role == 'ADMIN' or request.user.is_superuser: + users = User.objects.filter(is_active=True).order_by('email') + elif request.user.google.all(): + users = [] + else: + users = User.objects.filter(role='ADMIN').order_by('email') kwargs_data = { - "assigned_to": User.objects.filter(is_active=True).order_by('email'), + "assigned_to": users, "account": accounts, "contacts": contacts} if request.POST: form = OpportunityForm(request.POST, request.FILES, **kwargs_data) @@ -102,20 +122,23 @@ def create_opportunity(request): *request.POST.getlist('assigned_to')) assigned_to_list = request.POST.getlist('assigned_to') current_site = get_current_site(request) - for assigned_to_user in assigned_to_list: - user = get_object_or_404(User, pk=assigned_to_user) - mail_subject = 'Assigned to opportunity.' - message = render_to_string( - 'assigned_to/opportunity_assigned.html', { - 'user': user, - 'domain': current_site.domain, - 'protocol': request.scheme, - 'opportunity': opportunity_obj - }) - email = EmailMessage( - mail_subject, message, to=[user.email]) - email.content_subtype = "html" - email.send() + recipients = assigned_to_list + send_email_to_assigned_user.delay(recipients, opportunity_obj.id, domain=current_site.domain, + protocol=request.scheme) + # for assigned_to_user in assigned_to_list: + # user = get_object_or_404(User, pk=assigned_to_user) + # mail_subject = 'Assigned to opportunity.' + # message = render_to_string( + # 'assigned_to/opportunity_assigned.html', { + # 'user': user, + # 'domain': current_site.domain, + # 'protocol': request.scheme, + # 'opportunity': opportunity_obj + # }) + # email = EmailMessage( + # mail_subject, message, to=[user.email]) + # email.content_subtype = "html" + # email.send() if request.POST.getlist('contacts', []): opportunity_obj.contacts.add( *request.POST.getlist('contacts')) @@ -197,10 +220,19 @@ class OpportunityDetailView(LoginRequiredMixin, DetailView): assigned_data.append(assigned_dict) comments = context["opportunity_record"].opportunity_comments.all() + + if self.request.user.is_superuser or self.request.user.role == 'ADMIN': + users_mention = list(User.objects.all().values('username')) + elif self.request.user != context['object'].created_by: + users_mention = [{'username': context['object'].created_by.username}] + else: + users_mention = list(context['object'].assigned_to.all().values('username')) + context.update({ "comments": comments, 'attachments': context[ "opportunity_record"].opportunity_attachment.all(), + "users_mention": users_mention, "assigned_data": json.dumps(assigned_data)}) return context @@ -214,9 +246,15 @@ def update_opportunity(request, pk): created_by=request.user) contacts = Contact.objects.filter( Q(assigned_to__in=[request.user]) | Q(created_by=request.user)) - + users = [] + if request.user.role == 'ADMIN' or request.user.is_superuser: + users = User.objects.filter(is_active=True).order_by('email') + elif request.user.google.all(): + users = [] + else: + users = User.objects.filter(role='ADMIN').order_by('email') kwargs_data = { - "assigned_to": User.objects.filter(is_active=True).order_by('email'), + "assigned_to": users, "account": accounts, "contacts": contacts} form = OpportunityForm(instance=opportunity_object, **kwargs_data) @@ -241,21 +279,25 @@ def update_opportunity(request, pk): all_members_list = list( set(list(assigned_form_users)) - set(list(assigned_to_ids))) - if all_members_list: - for assigned_to_user in all_members_list: - user = get_object_or_404(User, pk=assigned_to_user) - mail_subject = 'Assigned to opportunity.' - message = render_to_string( - 'assigned_to/opportunity_assigned.html', { - 'user': user, - 'domain': current_site.domain, - 'protocol': request.scheme, - 'opportunity': opportunity_obj - }) - email = EmailMessage( - mail_subject, message, to=[user.email]) - email.content_subtype = "html" - email.send() + current_site = get_current_site(request) + recipients = all_members_list + send_email_to_assigned_user.delay(recipients, opportunity_obj.id, domain=current_site.domain, + protocol=request.scheme) + # if all_members_list: + # for assigned_to_user in all_members_list: + # user = get_object_or_404(User, pk=assigned_to_user) + # mail_subject = 'Assigned to opportunity.' + # message = render_to_string( + # 'assigned_to/opportunity_assigned.html', { + # 'user': user, + # 'domain': current_site.domain, + # 'protocol': request.scheme, + # 'opportunity': opportunity_obj + # }) + # email = EmailMessage( + # mail_subject, message, to=[user.email]) + # email.content_subtype = "html" + # email.send() opportunity_obj.assigned_to.clear() opportunity_obj.assigned_to.add( @@ -384,6 +426,10 @@ class AddCommentView(LoginRequiredMixin, CreateView): comment.commented_by = self.request.user comment.opportunity = self.opportunity comment.save() + comment_id = comment.id + current_site = get_current_site(self.request) + send_email_user_mentions.delay(comment_id, 'opportunity', domain=current_site.domain, + protocol=self.request.scheme) return JsonResponse({ "comment_id": comment.id, "comment": comment.comment, "commented_on": comment.commented_on, @@ -413,6 +459,10 @@ class UpdateCommentView(LoginRequiredMixin, View): def form_valid(self, form): self.comment_obj.comment = form.cleaned_data.get("comment") self.comment_obj.save(update_fields=["comment"]) + comment_id = self.comment_obj.id + current_site = get_current_site(self.request) + send_email_user_mentions.delay(comment_id, 'opportunity', domain=current_site.domain, + protocol=self.request.scheme) return JsonResponse({ "commentid": self.comment_obj.id, "comment": self.comment_obj.comment, @@ -479,7 +529,8 @@ class AddAttachmentsView(LoginRequiredMixin, CreateView): "created_by": attachment.created_by.email, "download_url": reverse('common:download_attachment', kwargs={'pk': attachment.id}), - "attachment_display": attachment.get_file_type_display() + "attachment_display": attachment.get_file_type_display(), + "file_type": attachment.file_type() }) def form_invalid(self, form): diff --git a/requirements.txt b/requirements.txt index 381420ee..a128e721 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==2.1.7 +Django==2.2.2 boto==2.49.0 boto3==1.9.94 django-simple-pagination==1.3 @@ -8,6 +8,7 @@ psycopg2-binary==2.7.7 redis django-compressor==2.2 coverage +pdfkit==0.6.1 pytest==4.1.1 pytest-cov==2.6.1 pytest-django==3.4.5 diff --git a/static/css/bootstrap-suggest.css b/static/css/bootstrap-suggest.css new file mode 100644 index 00000000..e6263359 --- /dev/null +++ b/static/css/bootstrap-suggest.css @@ -0,0 +1,27 @@ +/*! + * bootstra-suggest - v2.0.1 (https://github.com/lodev09/bootstrap-suggest#readme) + * Copyright 2013-2019 Jovanni Lo (lodev09@gmail.com) + * Licensed under MIT (https://github.com/lodev09/bootstrap-suggest/blob/master/LICENSE) + */ + +.suggest { + /* position: relative; */ + top: 7px; + z-index: 30; + text-align: left; +} + +.suggest > .dropdown-menu { + margin-top: 15px; + position: absolute; +} + +.suggest > .dropdown-menu > a.dropdown-item { + border-top: 1px solid #eeeeee; + padding: 5px 10px; +} + +.suggest > .dropdown-menu > a.dropdown-item:first-child { + border-top: 0; +} + diff --git a/static/css/main.scss b/static/css/main.scss index 57b3fb5c..6c583f50 100644 --- a/static/css/main.scss +++ b/static/css/main.scss @@ -535,7 +535,7 @@ $color_2: #000; tr{ th{ color: #454545; - font-weight: 500; + font-weight: 900; font-size: 0.9rem; border: none; text-align: left; diff --git a/static/js/ajaxForm.js b/static/js/ajaxForm.js new file mode 100644 index 00000000..c67fc332 --- /dev/null +++ b/static/js/ajaxForm.js @@ -0,0 +1,1277 @@ +/*! + * jQuery Form Plugin + * version: 3.51.0-2014.06.20 + * Requires jQuery v1.5 or later + * Copyright (c) 2014 M. Alsup + * Examples and documentation at: http://malsup.com/jquery/form/ + * Project repository: https://github.com/malsup/form + * Dual licensed under the MIT and GPL licenses. + * https://github.com/malsup/form#copyright-and-license + */ +/*global ActiveXObject */ + +// AMD support +(function (factory) { + "use strict"; + if (typeof define === 'function' && define.amd) { + // using AMD; register as anon module + define(['jquery'], factory); + } else { + // no AMD; invoke directly + factory( (typeof(jQuery) != 'undefined') ? jQuery : window.Zepto ); + } +} + +(function($) { +"use strict"; + +/* + Usage Note: + ----------- + Do not use both ajaxSubmit and ajaxForm on the same form. These + functions are mutually exclusive. Use ajaxSubmit if you want + to bind your own submit handler to the form. For example, + + $(document).ready(function() { + $('#myForm').on('submit', function(e) { + e.preventDefault(); // <-- important + $(this).ajaxSubmit({ + target: '#output' + }); + }); + }); + + Use ajaxForm when you want the plugin to manage all the event binding + for you. For example, + + $(document).ready(function() { + $('#myForm').ajaxForm({ + target: '#output' + }); + }); + + You can also use ajaxForm with delegation (requires jQuery v1.7+), so the + form does not have to exist when you invoke ajaxForm: + + $('#myForm').ajaxForm({ + delegation: true, + target: '#output' + }); + + When using ajaxForm, the ajaxSubmit function will be invoked for you + at the appropriate time. +*/ + +/** + * Feature detection + */ +var feature = {}; +feature.fileapi = $("").get(0).files !== undefined; +feature.formdata = window.FormData !== undefined; + +var hasProp = !!$.fn.prop; + +// attr2 uses prop when it can but checks the return type for +// an expected string. this accounts for the case where a form +// contains inputs with names like "action" or "method"; in those +// cases "prop" returns the element +$.fn.attr2 = function() { + if ( ! hasProp ) { + return this.attr.apply(this, arguments); + } + var val = this.prop.apply(this, arguments); + if ( ( val && val.jquery ) || typeof val === 'string' ) { + return val; + } + return this.attr.apply(this, arguments); +}; + +/** + * ajaxSubmit() provides a mechanism for immediately submitting + * an HTML form using AJAX. + */ +$.fn.ajaxSubmit = function(options) { + /*jshint scripturl:true */ + + // fast fail if nothing selected (http://dev.jquery.com/ticket/2752) + if (!this.length) { + log('ajaxSubmit: skipping submit process - no element selected'); + return this; + } + + var method, action, url, $form = this; + + if (typeof options == 'function') { + options = { success: options }; + } + else if ( options === undefined ) { + options = {}; + } + + method = options.type || this.attr2('method'); + action = options.url || this.attr2('action'); + + url = (typeof action === 'string') ? $.trim(action) : ''; + url = url || window.location.href || ''; + if (url) { + // clean url (don't include hash vaue) + url = (url.match(/^([^#]+)/)||[])[1]; + } + + options = $.extend(true, { + url: url, + success: $.ajaxSettings.success, + type: method || $.ajaxSettings.type, + iframeSrc: /^https/i.test(window.location.href || '') ? 'javascript:false' : 'about:blank' + }, options); + + // hook for manipulating the form data before it is extracted; + // convenient for use with rich editors like tinyMCE or FCKEditor + var veto = {}; + this.trigger('form-pre-serialize', [this, options, veto]); + if (veto.veto) { + log('ajaxSubmit: submit vetoed via form-pre-serialize trigger'); + return this; + } + + // provide opportunity to alter form data before it is serialized + if (options.beforeSerialize && options.beforeSerialize(this, options) === false) { + log('ajaxSubmit: submit aborted via beforeSerialize callback'); + return this; + } + + var traditional = options.traditional; + if ( traditional === undefined ) { + traditional = $.ajaxSettings.traditional; + } + + var elements = []; + var qx, a = this.formToArray(options.semantic, elements); + if (options.data) { + options.extraData = options.data; + qx = $.param(options.data, traditional); + } + + // give pre-submit callback an opportunity to abort the submit + if (options.beforeSubmit && options.beforeSubmit(a, this, options) === false) { + log('ajaxSubmit: submit aborted via beforeSubmit callback'); + return this; + } + + // fire vetoable 'validate' event + this.trigger('form-submit-validate', [a, this, options, veto]); + if (veto.veto) { + log('ajaxSubmit: submit vetoed via form-submit-validate trigger'); + return this; + } + + var q = $.param(a, traditional); + if (qx) { + q = ( q ? (q + '&' + qx) : qx ); + } + if (options.type.toUpperCase() == 'GET') { + options.url += (options.url.indexOf('?') >= 0 ? '&' : '?') + q; + options.data = null; // data is null for 'get' + } + else { + options.data = q; // data is the query string for 'post' + } + + var callbacks = []; + if (options.resetForm) { + callbacks.push(function() { $form.resetForm(); }); + } + if (options.clearForm) { + callbacks.push(function() { $form.clearForm(options.includeHidden); }); + } + + // perform a load on the target only if dataType is not provided + if (!options.dataType && options.target) { + var oldSuccess = options.success || function(){}; + callbacks.push(function(data) { + var fn = options.replaceTarget ? 'replaceWith' : 'html'; + $(options.target)[fn](data).each(oldSuccess, arguments); + }); + } + else if (options.success) { + callbacks.push(options.success); + } + + options.success = function(data, status, xhr) { // jQuery 1.4+ passes xhr as 3rd arg + var context = options.context || this ; // jQuery 1.4+ supports scope context + for (var i=0, max=callbacks.length; i < max; i++) { + callbacks[i].apply(context, [data, status, xhr || $form, $form]); + } + }; + + if (options.error) { + var oldError = options.error; + options.error = function(xhr, status, error) { + var context = options.context || this; + oldError.apply(context, [xhr, status, error, $form]); + }; + } + + if (options.complete) { + var oldComplete = options.complete; + options.complete = function(xhr, status) { + var context = options.context || this; + oldComplete.apply(context, [xhr, status, $form]); + }; + } + + // are there files to upload? + + // [value] (issue #113), also see comment: + // https://github.com/malsup/form/commit/588306aedba1de01388032d5f42a60159eea9228#commitcomment-2180219 + var fileInputs = $('input[type=file]:enabled', this).filter(function() { return $(this).val() !== ''; }); + + var hasFileInputs = fileInputs.length > 0; + var mp = 'multipart/form-data'; + var multipart = ($form.attr('enctype') == mp || $form.attr('encoding') == mp); + + var fileAPI = feature.fileapi && feature.formdata; + log("fileAPI :" + fileAPI); + var shouldUseFrame = (hasFileInputs || multipart) && !fileAPI; + + var jqxhr; + + // options.iframe allows user to force iframe mode + // 06-NOV-09: now defaulting to iframe mode if file input is detected + if (options.iframe !== false && (options.iframe || shouldUseFrame)) { + // hack to fix Safari hang (thanks to Tim Molendijk for this) + // see: http://groups.google.com/group/jquery-dev/browse_thread/thread/36395b7ab510dd5d + if (options.closeKeepAlive) { + $.get(options.closeKeepAlive, function() { + jqxhr = fileUploadIframe(a); + }); + } + else { + jqxhr = fileUploadIframe(a); + } + } + else if ((hasFileInputs || multipart) && fileAPI) { + jqxhr = fileUploadXhr(a); + } + else { + jqxhr = $.ajax(options); + } + + $form.removeData('jqxhr').data('jqxhr', jqxhr); + + // clear element array + for (var k=0; k < elements.length; k++) { + elements[k] = null; + } + + // fire 'notify' event + this.trigger('form-submit-notify', [this, options]); + return this; + + // utility fn for deep serialization + function deepSerialize(extraData){ + var serialized = $.param(extraData, options.traditional).split('&'); + var len = serialized.length; + var result = []; + var i, part; + for (i=0; i < len; i++) { + // #252; undo param space replacement + serialized[i] = serialized[i].replace(/\+/g,' '); + part = serialized[i].split('='); + // #278; use array instead of object storage, favoring array serializations + result.push([decodeURIComponent(part[0]), decodeURIComponent(part[1])]); + } + return result; + } + + // XMLHttpRequest Level 2 file uploads (big hat tip to francois2metz) + function fileUploadXhr(a) { + var formdata = new FormData(); + + for (var i=0; i < a.length; i++) { + formdata.append(a[i].name, a[i].value); + } + + if (options.extraData) { + var serializedData = deepSerialize(options.extraData); + for (i=0; i < serializedData.length; i++) { + if (serializedData[i]) { + formdata.append(serializedData[i][0], serializedData[i][1]); + } + } + } + + options.data = null; + + var s = $.extend(true, {}, $.ajaxSettings, options, { + contentType: false, + processData: false, + cache: false, + type: method || 'POST' + }); + + if (options.uploadProgress) { + // workaround because jqXHR does not expose upload property + s.xhr = function() { + var xhr = $.ajaxSettings.xhr(); + if (xhr.upload) { + xhr.upload.addEventListener('progress', function(event) { + var percent = 0; + var position = event.loaded || event.position; /*event.position is deprecated*/ + var total = event.total; + if (event.lengthComputable) { + percent = Math.ceil(position / total * 100); + } + options.uploadProgress(event, position, total, percent); + }, false); + } + return xhr; + }; + } + + s.data = null; + var beforeSend = s.beforeSend; + s.beforeSend = function(xhr, o) { + //Send FormData() provided by user + if (options.formData) { + o.data = options.formData; + } + else { + o.data = formdata; + } + if(beforeSend) { + beforeSend.call(this, xhr, o); + } + }; + return $.ajax(s); + } + + // private function for handling file uploads (hat tip to YAHOO!) + function fileUploadIframe(a) { + var form = $form[0], el, i, s, g, id, $io, io, xhr, sub, n, timedOut, timeoutHandle; + var deferred = $.Deferred(); + + // #341 + deferred.abort = function(status) { + xhr.abort(status); + }; + + if (a) { + // ensure that every serialized input is still enabled + for (i=0; i < elements.length; i++) { + el = $(elements[i]); + if ( hasProp ) { + el.prop('disabled', false); + } + else { + el.removeAttr('disabled'); + } + } + } + + s = $.extend(true, {}, $.ajaxSettings, options); + s.context = s.context || s; + id = 'jqFormIO' + (new Date().getTime()); + if (s.iframeTarget) { + $io = $(s.iframeTarget); + n = $io.attr2('name'); + if (!n) { + $io.attr2('name', id); + } + else { + id = n; + } + } + else { + $io = $('