Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend
commit
891a06df28
@ -0,0 +1 @@ |
|||||||
|
This is a django customer relationship managemement app. |
||||||
@ -0,0 +1,463 @@ |
|||||||
|
from django.http import HttpResponseRedirect |
||||||
|
from django.contrib import admin |
||||||
|
from django.urls import path, reverse |
||||||
|
from django.contrib import messages |
||||||
|
from django.shortcuts import render, redirect |
||||||
|
from django.contrib.auth import get_user_model |
||||||
|
from django.utils.html import format_html |
||||||
|
from django.core.mail import send_mail |
||||||
|
|
||||||
|
import csv |
||||||
|
import io |
||||||
|
import time |
||||||
|
import logging |
||||||
|
|
||||||
|
from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate |
||||||
|
from .forms import FileImportForm, EmailTemplateSelectionForm |
||||||
|
from .filters import StaffUserFilter, ProspectProfileFilter |
||||||
|
|
||||||
|
from tournaments.models import CustomUser |
||||||
|
from tournaments.models.enums import UserOrigin |
||||||
|
from sync.admin import SyncedObjectAdmin |
||||||
|
|
||||||
|
User = get_user_model() |
||||||
|
|
||||||
|
logger = logging.getLogger(__name__) |
||||||
|
|
||||||
|
@admin.register(Entity) |
||||||
|
class EntityAdmin(SyncedObjectAdmin): |
||||||
|
list_display = ('name', 'address', 'zip_code', 'city') |
||||||
|
search_fields = ('name', 'address', 'zip_code', 'city') |
||||||
|
|
||||||
|
@admin.register(EmailTemplate) |
||||||
|
class EmailTemplateAdmin(SyncedObjectAdmin): |
||||||
|
list_display = ('name', 'subject', 'body') |
||||||
|
search_fields = ('name', 'subject') |
||||||
|
|
||||||
|
def contacted_by_sms(modeladmin, request, queryset): |
||||||
|
create_activity_for_prospect(modeladmin, request, queryset, None, Status.CONTACTED) |
||||||
|
contacted_by_sms.short_description = "Contacted by SMS" |
||||||
|
|
||||||
|
def mark_as_customer(modeladmin, request, queryset): |
||||||
|
create_activity_for_prospect(modeladmin, request, queryset, None, Status.CUSTOMER) |
||||||
|
mark_as_customer.short_description = "Mark as customer" |
||||||
|
|
||||||
|
def mark_as_should_test(modeladmin, request, queryset): |
||||||
|
create_activity_for_prospect(modeladmin, request, queryset, None, Status.TESTING) |
||||||
|
mark_as_should_test.short_description = "Mark as should test" |
||||||
|
|
||||||
|
def mark_as_testing(modeladmin, request, queryset): |
||||||
|
create_activity_for_prospect(modeladmin, request, queryset, None, Status.CUSTOMER) |
||||||
|
mark_as_testing.short_description = "Mark as testing" |
||||||
|
|
||||||
|
def declined_too_expensive(modeladmin, request, queryset): |
||||||
|
create_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED_TOO_EXPENSIVE) |
||||||
|
declined_too_expensive.short_description = "Declined too expensive" |
||||||
|
|
||||||
|
def declined_use_something_else(modeladmin, request, queryset): |
||||||
|
create_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED_USE_SOMETHING_ELSE) |
||||||
|
declined_use_something_else.short_description = "Declined use something else" |
||||||
|
|
||||||
|
def create_activity_for_prospect(modeladmin, request, queryset, type, status): |
||||||
|
for prospect in queryset: |
||||||
|
activity = Activity.objects.create( |
||||||
|
type=type, |
||||||
|
status=status, |
||||||
|
related_user = request.user |
||||||
|
) |
||||||
|
activity.prospects.add(prospect) |
||||||
|
|
||||||
|
modeladmin.message_user( |
||||||
|
request, |
||||||
|
f'{queryset.count()} prospects were marked as {status}.' |
||||||
|
) |
||||||
|
|
||||||
|
def create_activity_for_prospect(modeladmin, request, queryset): |
||||||
|
# Only allow single selection |
||||||
|
if queryset.count() != 1: |
||||||
|
messages.error(request, "Please select exactly one prospect.") |
||||||
|
return |
||||||
|
|
||||||
|
prospect = queryset.first() |
||||||
|
|
||||||
|
# Build the URL with pre-populated fields |
||||||
|
url = reverse('admin:bizdev_activity_add') |
||||||
|
url += f'?prospect={prospect.id}' |
||||||
|
|
||||||
|
# You can add more fields as URL parameters |
||||||
|
# url += f'&title=Event for {prospect.name}&status=pending' |
||||||
|
|
||||||
|
return redirect(url) |
||||||
|
create_activity_for_prospect.short_description = "Create event" |
||||||
|
|
||||||
|
@admin.register(Prospect) |
||||||
|
class ProspectAdmin(SyncedObjectAdmin): |
||||||
|
readonly_fields = ['related_events', 'entity_names', 'current_status'] |
||||||
|
fieldsets = [ |
||||||
|
(None, { |
||||||
|
'fields': ['related_events', 'entity_names', 'first_name', 'last_name', 'email', 'phone', 'name_unsure', 'official_user', 'entities'] |
||||||
|
}), |
||||||
|
] |
||||||
|
list_display = ('entity_names', 'first_name', 'last_name', 'email', 'last_update', 'current_status') |
||||||
|
list_filter = ('creation_date', StaffUserFilter, 'source', ProspectProfileFilter) |
||||||
|
search_fields = ('first_name', 'last_name', 'email') |
||||||
|
date_hierarchy = 'creation_date' |
||||||
|
change_list_template = "admin/bizdev/prospect/change_list.html" |
||||||
|
ordering = ['-last_update'] |
||||||
|
filter_horizontal = ['entities'] |
||||||
|
actions = ['send_email', create_activity_for_prospect, contacted_by_sms, mark_as_should_test, mark_as_testing, mark_as_customer, declined_too_expensive, declined_use_something_else] |
||||||
|
|
||||||
|
def related_events(self, obj): |
||||||
|
events = obj.events.all() |
||||||
|
if events: |
||||||
|
event_links = [] |
||||||
|
for event in events: |
||||||
|
url = f"/kingdom/bizdev/event/{event.id}/change/" |
||||||
|
event_links.append(f'<a href="{url}">{event.html_desc()}</a>') |
||||||
|
return format_html('<br>'.join(event_links)) |
||||||
|
return "No events" |
||||||
|
|
||||||
|
related_events.short_description = "Related Events" |
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None): |
||||||
|
# Add the URL with a filter for the current user |
||||||
|
user_filter_url = "{}?related_user__id__exact={}".format( |
||||||
|
reverse('admin:bizdev_prospect_changelist'), |
||||||
|
request.user.id |
||||||
|
) |
||||||
|
extra_context = extra_context or {} |
||||||
|
extra_context['user_filter_url'] = user_filter_url |
||||||
|
return super().changelist_view(request, extra_context=extra_context) |
||||||
|
|
||||||
|
def get_urls(self): |
||||||
|
urls = super().get_urls() |
||||||
|
custom_urls = [ |
||||||
|
path('import_file/', self.admin_site.admin_view(self.import_file), name='import_file'), |
||||||
|
path('import_app_users/', self.admin_site.admin_view(self.import_app_users), name='import_app_users'), |
||||||
|
path('cleanup/', self.admin_site.admin_view(self.cleanup), name='cleanup'), |
||||||
|
] |
||||||
|
return custom_urls + urls |
||||||
|
|
||||||
|
def cleanup(self, request): |
||||||
|
Entity.objects.all().delete() |
||||||
|
Prospect.objects.all().delete() |
||||||
|
Activity.objects.all().delete() |
||||||
|
|
||||||
|
messages.success(request, 'cleanup bizdev objects') |
||||||
|
return redirect('admin:bizdev_prospect_changelist') |
||||||
|
|
||||||
|
def import_app_users(self, request): |
||||||
|
users = CustomUser.objects.filter(origin=UserOrigin.APP) |
||||||
|
|
||||||
|
created_count = 0 |
||||||
|
for user in users: |
||||||
|
is_customer = user.purchases.count() > 0 |
||||||
|
entity_name = user.latest_event_club_name() |
||||||
|
|
||||||
|
prospect, prospect_created = Prospect.objects.get_or_create( |
||||||
|
email=user.email, |
||||||
|
defaults={ |
||||||
|
'first_name': user.first_name, |
||||||
|
'last_name': user.last_name, |
||||||
|
'phone': user.phone, |
||||||
|
'name_unsure': False, |
||||||
|
'official_user': user, |
||||||
|
'source': 'App', |
||||||
|
} |
||||||
|
) |
||||||
|
if entity_name: |
||||||
|
entity, entity_created = Entity.objects.get_or_create( |
||||||
|
name=entity_name, |
||||||
|
defaults={'name': entity_name} |
||||||
|
) |
||||||
|
prospect.entities.add(entity) |
||||||
|
|
||||||
|
if is_customer: |
||||||
|
activity = Activity.objects.create( |
||||||
|
status=Status.CUSTOMER, |
||||||
|
) |
||||||
|
activity.prospects.add(prospect) |
||||||
|
if prospect_created: |
||||||
|
created_count += 1 |
||||||
|
|
||||||
|
messages.success(request, f'Imported {created_count} app users into prospects') |
||||||
|
return redirect('admin:bizdev_prospect_changelist') |
||||||
|
|
||||||
|
def import_file(self, request): |
||||||
|
""" |
||||||
|
Handle file import - displays form and processes file upload |
||||||
|
""" |
||||||
|
if request.method == 'POST': |
||||||
|
form = FileImportForm(request.POST, request.FILES) |
||||||
|
if form.is_valid(): |
||||||
|
# Call the import_csv method with the uploaded file |
||||||
|
try: |
||||||
|
result = self.import_csv(form.cleaned_data['file'], form.cleaned_data['source']) |
||||||
|
messages.success(request, f'File imported successfully: {result}') |
||||||
|
return redirect('admin:bizdev_prospect_changelist') |
||||||
|
except Exception as e: |
||||||
|
messages.error(request, f'Error importing file: {str(e)}') |
||||||
|
else: |
||||||
|
messages.error(request, 'Please correct the errors below.') |
||||||
|
else: |
||||||
|
form = FileImportForm() |
||||||
|
|
||||||
|
context = { |
||||||
|
'form': form, |
||||||
|
'title': 'Import File', |
||||||
|
'app_label': self.model._meta.app_label, |
||||||
|
'opts': self.model._meta, |
||||||
|
'has_change_permission': self.has_change_permission(request), |
||||||
|
} |
||||||
|
return render(request, 'admin/bizdev/prospect/import_file.html', context) |
||||||
|
|
||||||
|
def import_csv(self, file, source): |
||||||
|
""" |
||||||
|
Process the uploaded CSV file |
||||||
|
CSV format: entity_name,last_name,first_name,email,phone,attachment_text,status,related_user |
||||||
|
""" |
||||||
|
try: |
||||||
|
# Read the file content |
||||||
|
file_content = file.read().decode('utf-8') |
||||||
|
csv_reader = csv.reader(io.StringIO(file_content)) |
||||||
|
|
||||||
|
created_prospects = 0 |
||||||
|
updated_prospects = 0 |
||||||
|
created_entities = 0 |
||||||
|
created_events = 0 |
||||||
|
|
||||||
|
for row in csv_reader: |
||||||
|
if len(row) < 8: |
||||||
|
continue # Skip rows that don't have enough columns |
||||||
|
|
||||||
|
entity_name = row[0].strip() |
||||||
|
last_name = row[1].strip() |
||||||
|
first_name = row[2].strip() |
||||||
|
email = row[3].strip() |
||||||
|
phone = row[4].strip() if row[4].strip() else None |
||||||
|
if phone and not phone.startswith('0'): |
||||||
|
phone = '0' + phone |
||||||
|
attachment_text = row[5].strip() if row[5].strip() else None |
||||||
|
status_text = row[6].strip() if row[6].strip() else None |
||||||
|
related_user_name = row[7].strip() if row[7].strip() else None |
||||||
|
|
||||||
|
# Create or get Entity |
||||||
|
entity = None |
||||||
|
if entity_name: |
||||||
|
entity, entity_created = Entity.objects.get_or_create( |
||||||
|
name=entity_name, |
||||||
|
defaults={'name': entity_name} |
||||||
|
) |
||||||
|
if entity_created: |
||||||
|
created_entities += 1 |
||||||
|
|
||||||
|
# Get related user if provided |
||||||
|
related_user = None |
||||||
|
if related_user_name: |
||||||
|
try: |
||||||
|
related_user = User.objects.get(username=related_user_name) |
||||||
|
except User.DoesNotExist: |
||||||
|
# Try to find by first name if username doesn't exist |
||||||
|
related_user = User.objects.filter(first_name__icontains=related_user_name).first() |
||||||
|
|
||||||
|
# Create or update Prospect |
||||||
|
prospect, prospect_created = Prospect.objects.get_or_create( |
||||||
|
email=email, |
||||||
|
defaults={ |
||||||
|
'first_name': first_name, |
||||||
|
'last_name': last_name, |
||||||
|
'phone': phone, |
||||||
|
'name_unsure': False, |
||||||
|
'related_user': related_user, |
||||||
|
'source': source, |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
if prospect_created: |
||||||
|
created_prospects += 1 |
||||||
|
else: |
||||||
|
# Check if names are different and mark as name_unsure |
||||||
|
if (prospect.first_name != first_name or prospect.last_name != last_name): |
||||||
|
prospect.name_unsure = True |
||||||
|
# Update related_user if provided |
||||||
|
if related_user: |
||||||
|
prospect.related_user = related_user |
||||||
|
prospect.save() |
||||||
|
updated_prospects += 1 |
||||||
|
|
||||||
|
# Associate entity with prospect |
||||||
|
if entity: |
||||||
|
prospect.entities.add(entity) |
||||||
|
|
||||||
|
# Create Event if attachment_text or status is provided |
||||||
|
if attachment_text or status_text: |
||||||
|
# Map status text to Status enum |
||||||
|
status_value = Status.NONE |
||||||
|
if status_text: |
||||||
|
if 'CONTACTED' in status_text: |
||||||
|
status_value = Status.CONTACTED |
||||||
|
elif 'RESPONDED' in status_text: |
||||||
|
status_value = Status.RESPONDED |
||||||
|
elif 'SHOULD_TEST' in status_text: |
||||||
|
status_value = Status.SHOULD_TEST |
||||||
|
elif 'CUSTOMER' in status_text: |
||||||
|
status_value = Status.CUSTOMER |
||||||
|
elif 'LOST' in status_text: |
||||||
|
status_value = Status.LOST |
||||||
|
elif 'DECLINED_TOO_EXPENSIVE' in status_text: |
||||||
|
status_value = Status.DECLINED_TOO_EXPENSIVE |
||||||
|
elif 'DECLINED_USE_SOMETHING_ELSE' in status_text: |
||||||
|
status_value = Status.DECLINED_USE_SOMETHING_ELSE |
||||||
|
elif 'DECLINED_UNRELATED' in status_text or 'NOK' in status_text: |
||||||
|
status_value = Status.DECLINED_UNRELATED |
||||||
|
|
||||||
|
activity = Activity.objects.create( |
||||||
|
type=ActivityType.SMS, |
||||||
|
attachment_text=attachment_text, |
||||||
|
status=status_value, |
||||||
|
description=f"Imported from CSV - Status: {status_text}" if status_text else "Imported from CSV" |
||||||
|
) |
||||||
|
activity.prospects.add(prospect) |
||||||
|
created_events += 1 |
||||||
|
|
||||||
|
result = f"Successfully imported: {created_prospects} new prospects, {updated_prospects} updated prospects, {created_entities} new entities, {created_events} new events" |
||||||
|
return result |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
raise Exception(f"Error processing CSV file: {str(e)}") |
||||||
|
|
||||||
|
def send_email(self, request, queryset): |
||||||
|
|
||||||
|
logger.info('send_email to prospects form initiated...') |
||||||
|
|
||||||
|
if 'apply' in request.POST: |
||||||
|
form = EmailTemplateSelectionForm(request.POST) |
||||||
|
if form.is_valid(): |
||||||
|
email_template = form.cleaned_data['email_template'] |
||||||
|
|
||||||
|
self.process_selected_items_with_template(request, queryset, email_template) |
||||||
|
|
||||||
|
self.message_user(request, f"Email sent to {queryset.count()} prospects using the '{email_template.name}' template.", messages.SUCCESS) |
||||||
|
return HttpResponseRedirect(request.get_full_path()) |
||||||
|
else: |
||||||
|
form = EmailTemplateSelectionForm() |
||||||
|
|
||||||
|
return render(request, 'admin/bizdev/select_email_template.html', { |
||||||
|
'prospects': queryset, |
||||||
|
'form': form, |
||||||
|
'title': 'Send Email to Prospects' |
||||||
|
}) |
||||||
|
send_email.short_description = "Send email" |
||||||
|
|
||||||
|
def process_selected_items_with_template(self, request, queryset, email_template): |
||||||
|
|
||||||
|
sent_count = 0 |
||||||
|
error_emails = [] |
||||||
|
all_emails = [] |
||||||
|
|
||||||
|
logger.info(f'Sending email to {queryset.count()} users...') |
||||||
|
|
||||||
|
for prospect in queryset: |
||||||
|
mail_body = email_template.body.replace('{{name}}', prospect.first_name) |
||||||
|
all_emails.append(prospect.email) |
||||||
|
|
||||||
|
try: |
||||||
|
send_mail( |
||||||
|
email_template.subject, |
||||||
|
mail_body, |
||||||
|
request.user.email, |
||||||
|
[prospect.email], |
||||||
|
fail_silently=False, |
||||||
|
) |
||||||
|
sent_count += 1 |
||||||
|
except Exception as e: |
||||||
|
error_emails.append(prospect.email) |
||||||
|
|
||||||
|
time.sleep(1) |
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Activity) |
||||||
|
class ActivityAdmin(SyncedObjectAdmin): |
||||||
|
list_display = ('creation_date', 'type', 'description', 'attachment_text') |
||||||
|
list_filter = ('status', 'type') |
||||||
|
search_fields = ('description',) |
||||||
|
filter_horizontal = ('prospects',) |
||||||
|
date_hierarchy = 'creation_date' |
||||||
|
|
||||||
|
def get_form(self, request, obj=None, **kwargs): |
||||||
|
form = super().get_form(request, obj, **kwargs) |
||||||
|
|
||||||
|
# Pre-populate fields from URL parameters |
||||||
|
if 'prospect' in request.GET: |
||||||
|
try: |
||||||
|
prospect_id = request.GET['prospect'] |
||||||
|
prospect = Prospect.objects.get(id=prospect_id) |
||||||
|
form.base_fields['prospects'].initial = prospect |
||||||
|
form.base_fields['related_user'].initial = request.user |
||||||
|
|
||||||
|
# You can set other fields based on the prospect |
||||||
|
# form.base_fields['title'].initial = f"Event for {prospect.}" |
||||||
|
# form.base_fields['status'].initial = 'pending' |
||||||
|
|
||||||
|
except (Prospect.DoesNotExist, ValueError): |
||||||
|
pass |
||||||
|
|
||||||
|
return form |
||||||
|
|
||||||
|
def get_event_display(self, obj): |
||||||
|
return str(obj) |
||||||
|
get_event_display.short_description = 'Activity' |
||||||
|
|
||||||
|
# @admin.register(EmailCampaign) |
||||||
|
# class EmailCampaignAdmin(admin.ModelAdmin): |
||||||
|
# list_display = ('subject', 'event', 'sent_at') |
||||||
|
# list_filter = ('sent_at',) |
||||||
|
# search_fields = ('subject', 'content') |
||||||
|
# date_hierarchy = 'sent_at' |
||||||
|
# readonly_fields = ('sent_at',) |
||||||
|
|
||||||
|
# @admin.register(EmailTracker) |
||||||
|
# class EmailTrackerAdmin(admin.ModelAdmin): |
||||||
|
# list_display = ( |
||||||
|
# 'campaign', |
||||||
|
# 'prospect', |
||||||
|
# 'tracking_id', |
||||||
|
# 'sent_status', |
||||||
|
# 'opened_status', |
||||||
|
# 'clicked_status' |
||||||
|
# ) |
||||||
|
# list_filter = ('sent', 'opened', 'clicked') |
||||||
|
# search_fields = ( |
||||||
|
# 'prospect__name', |
||||||
|
# 'prospect__email', |
||||||
|
# 'campaign__subject' |
||||||
|
# ) |
||||||
|
# readonly_fields = ( |
||||||
|
# 'tracking_id', 'sent', 'sent_at', |
||||||
|
# 'opened', 'opened_at', |
||||||
|
# 'clicked', 'clicked_at' |
||||||
|
# ) |
||||||
|
# date_hierarchy = 'sent_at' |
||||||
|
|
||||||
|
# def sent_status(self, obj): |
||||||
|
# return self._get_status_html(obj.sent, obj.sent_at) |
||||||
|
# sent_status.short_description = 'Sent' |
||||||
|
# sent_status.allow_tags = True |
||||||
|
|
||||||
|
# def opened_status(self, obj): |
||||||
|
# return self._get_status_html(obj.opened, obj.opened_at) |
||||||
|
# opened_status.short_description = 'Opened' |
||||||
|
# opened_status.allow_tags = True |
||||||
|
|
||||||
|
# def clicked_status(self, obj): |
||||||
|
# return self._get_status_html(obj.clicked, obj.clicked_at) |
||||||
|
# clicked_status.short_description = 'Clicked' |
||||||
|
# clicked_status.allow_tags = True |
||||||
|
|
||||||
|
# def _get_status_html(self, status, date): |
||||||
|
# if status: |
||||||
|
# return format_html( |
||||||
|
# '<span style="color: green;">✓</span> {}', |
||||||
|
# date.strftime('%Y-%m-%d %H:%M') if date else '' |
||||||
|
# ) |
||||||
|
# return format_html('<span style="color: red;">✗</span>') |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
from django.urls import path |
||||||
|
from django.http import HttpResponse |
||||||
|
from tournaments.models import CustomUser |
||||||
|
from tournaments.models.enums import UserOrigin |
||||||
|
from django.core.mail import send_mail |
||||||
|
|
||||||
|
import time |
||||||
|
|
||||||
|
def users_list(with_tournaments): |
||||||
|
return CustomUser.objects.filter(origin=UserOrigin.APP).exclude(purchase__isnull=False).filter(events__isnull=with_tournaments) |
||||||
|
|
||||||
|
def email_users_with_tournaments_count(request): |
||||||
|
users = users_list(False) |
||||||
|
emails = [user.email for user in users] |
||||||
|
return HttpResponse(f'users = {len(users)}, \n\nemails = {emails}') |
||||||
|
|
||||||
|
def email_users_count(request): |
||||||
|
users = users_list(True) |
||||||
|
emails = [user.email for user in users] |
||||||
|
return HttpResponse(f'users = {len(users)}, \n\nemails = {emails}') |
||||||
|
|
||||||
|
def email_users_view(request): |
||||||
|
return email_users(request, users_list(True), 0) |
||||||
|
|
||||||
|
def email_users_with_tournaments(request): |
||||||
|
return email_users(request, users_list(False), 1) |
||||||
|
|
||||||
|
def email_users(request, users, template_index): |
||||||
|
|
||||||
|
users = users_list(True) |
||||||
|
|
||||||
|
subject = 'check Padel Club' |
||||||
|
from_email = 'laurent@padelclub.app' |
||||||
|
|
||||||
|
sent_count = 0 |
||||||
|
error_emails = [] |
||||||
|
all_emails = [] |
||||||
|
|
||||||
|
for user in users: |
||||||
|
mail_body = template(user, template_index) # f'Bonjour {user.first_name}, cool la vie ?' |
||||||
|
all_emails.append(user.email) |
||||||
|
|
||||||
|
try: |
||||||
|
send_mail( |
||||||
|
subject, |
||||||
|
mail_body, |
||||||
|
from_email, |
||||||
|
[user.email], |
||||||
|
fail_silently=False, |
||||||
|
) |
||||||
|
sent_count += 1 |
||||||
|
except Exception as e: |
||||||
|
error_emails.append(user.email) |
||||||
|
|
||||||
|
time.sleep(1) |
||||||
|
|
||||||
|
return HttpResponse(f'users = {len(users)}, sent = {sent_count}, errors = {len(error_emails)}, \n\nemails = {all_emails}, \n\nerror emails = {error_emails}') |
||||||
|
|
||||||
|
def template(user, index): |
||||||
|
if index == 0: |
||||||
|
return f'Bonjour {user.first_name}, \n\n' |
||||||
|
else: |
||||||
|
return f'Bonjour {user.first_name}, \n\nJe te remercie d\'avoir téléchargé Padel Club. J\'ai pu voir que tu avais créé quelques tournois mais sans aller plus loin, est-ce que tu pourrais me dire ce qui t\'as freiné ?\n\nLaurent Morvillier' |
||||||
|
|
||||||
|
urlpatterns = [ |
||||||
|
path('email_users/', email_users_view, name='bizdev_email_users'), |
||||||
|
path('email_users_count/', email_users_count, name='bizdev_email_count'), |
||||||
|
path('email_users_with_tournaments_count/', email_users_with_tournaments_count, name='bizdev_email_with_tournaments_count'), |
||||||
|
path('email_users_with_tournaments/', email_users_with_tournaments, name='email_users_with_tournaments'), |
||||||
|
] |
||||||
@ -1,5 +1,5 @@ |
|||||||
from django.apps import AppConfig |
from django.apps import AppConfig |
||||||
|
|
||||||
class CrmConfig(AppConfig): |
class bizdevConfig(AppConfig): |
||||||
default_auto_field = 'django.db.models.BigAutoField' |
default_auto_field = 'django.db.models.BigAutoField' |
||||||
name = 'crm' |
name = 'bizdev' |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
import django_filters |
||||||
|
from django.db.models import Q |
||||||
|
from django.contrib.auth import get_user_model |
||||||
|
from django.contrib import admin |
||||||
|
from django.utils import timezone |
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta |
||||||
|
|
||||||
|
from .models import Activity, Prospect |
||||||
|
|
||||||
|
User = get_user_model() |
||||||
|
|
||||||
|
class ProspectFilter(django_filters.FilterSet): |
||||||
|
zip_code = django_filters.CharFilter(lookup_expr='istartswith', label='Code postal') |
||||||
|
activities = django_filters.ModelMultipleChoiceFilter( |
||||||
|
queryset=Activity.objects.all(), |
||||||
|
field_name='activities', |
||||||
|
) |
||||||
|
city = django_filters.CharFilter(lookup_expr='icontains', label='Ville') |
||||||
|
name = django_filters.CharFilter(method='filter_name', label='Nom') |
||||||
|
|
||||||
|
def filter_name(self, queryset, name, value): |
||||||
|
return queryset.filter( |
||||||
|
Q(first_name__icontains=value) | Q(last_name__icontains=value) | Q(entity_name__icontains=value) |
||||||
|
) |
||||||
|
|
||||||
|
class Meta: |
||||||
|
model = Prospect |
||||||
|
fields = ['name', 'city', 'activities', 'zip_code'] |
||||||
|
|
||||||
|
class StaffUserFilter(admin.SimpleListFilter): |
||||||
|
title = 'staff user' |
||||||
|
parameter_name = 'user' |
||||||
|
|
||||||
|
def lookups(self, request, model_admin): |
||||||
|
staff_users = User.objects.filter(is_staff=True) |
||||||
|
return [(user.id, user.username) for user in staff_users] |
||||||
|
|
||||||
|
def queryset(self, request, queryset): |
||||||
|
# Filter the queryset based on the selected user ID |
||||||
|
if self.value(): |
||||||
|
return queryset.filter(related_user__id=self.value()) |
||||||
|
return queryset |
||||||
|
|
||||||
|
class ProspectProfileFilter(admin.SimpleListFilter): |
||||||
|
title = 'Prospect profiles' # displayed in the admin UI |
||||||
|
parameter_name = 'profile' # URL parameter |
||||||
|
|
||||||
|
def lookups(self, request, model_admin): |
||||||
|
return ( |
||||||
|
('tournament_at_least_1_month_old', 'tournaments > 1 month old'), |
||||||
|
('no_tournaments', 'No tournaments'), |
||||||
|
) |
||||||
|
|
||||||
|
def queryset(self, request, queryset): |
||||||
|
if not self.value(): |
||||||
|
return queryset |
||||||
|
|
||||||
|
two_months_ago = timezone.now().date() - relativedelta(months=2) |
||||||
|
|
||||||
|
if self.value() == 'tournament_at_least_2_month_old': |
||||||
|
return queryset.filter( |
||||||
|
official_user__isnull=False, |
||||||
|
official_user__events__creation_date__lte=two_months_ago |
||||||
|
) |
||||||
|
elif self.value() == 'no_tournaments': |
||||||
|
return queryset.filter( |
||||||
|
official_user__isnull=False, |
||||||
|
official_user__events__isnull=True |
||||||
|
) |
||||||
@ -0,0 +1,61 @@ |
|||||||
|
from django import forms |
||||||
|
|
||||||
|
from .models import EmailTemplate |
||||||
|
|
||||||
|
# class SmallTextArea(forms.Textarea): |
||||||
|
# def __init__(self, *args, **kwargs): |
||||||
|
# kwargs.setdefault('attrs', {}) |
||||||
|
# kwargs['attrs'].update({ |
||||||
|
# 'rows': 2, |
||||||
|
# 'cols': 100, |
||||||
|
# 'style': 'height: 80px; width: 800px;' |
||||||
|
# }) |
||||||
|
# super().__init__(*args, **kwargs) |
||||||
|
|
||||||
|
# class ProspectForm(forms.ModelForm): |
||||||
|
# class Meta: |
||||||
|
# model = Prospect |
||||||
|
# fields = ['entity_name', 'first_name', 'last_name', 'email', |
||||||
|
# 'phone', 'address', 'zip_code', 'city'] |
||||||
|
|
||||||
|
# class BulkEmailForm(forms.Form): |
||||||
|
# prospects = forms.ModelMultipleChoiceField( |
||||||
|
# queryset=Prospect.objects.all(), |
||||||
|
# widget=forms.CheckboxSelectMultiple |
||||||
|
# ) |
||||||
|
# subject = forms.CharField(max_length=200) |
||||||
|
# content = forms.CharField(widget=forms.Textarea) |
||||||
|
|
||||||
|
# class EventForm(forms.ModelForm): |
||||||
|
# prospects = forms.ModelMultipleChoiceField( |
||||||
|
# queryset=Prospect.objects.all(), |
||||||
|
# widget=forms.SelectMultiple(attrs={'class': 'select2'}), |
||||||
|
# required=False |
||||||
|
# ) |
||||||
|
# description = forms.CharField(widget=SmallTextArea) |
||||||
|
# attachment_text = forms.CharField(widget=SmallTextArea) |
||||||
|
|
||||||
|
# class Meta: |
||||||
|
# model = Event |
||||||
|
# fields = ['date', 'type', 'description', 'attachment_text', 'prospects', 'status'] |
||||||
|
# widgets = { |
||||||
|
# 'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}), |
||||||
|
# } |
||||||
|
|
||||||
|
class FileImportForm(forms.Form): |
||||||
|
source = forms.CharField(max_length=200) |
||||||
|
file = forms.FileField( |
||||||
|
label='Select file to import', |
||||||
|
help_text='Choose a file to upload and process', |
||||||
|
widget=forms.FileInput(attrs={'accept': '.csv,.xlsx,.xls,.txt'}) |
||||||
|
) |
||||||
|
|
||||||
|
class CSVImportForm(forms.Form): |
||||||
|
csv_file = forms.FileField() |
||||||
|
|
||||||
|
class EmailTemplateSelectionForm(forms.Form): |
||||||
|
email_template = forms.ModelChoiceField( |
||||||
|
queryset=EmailTemplate.objects.all(), |
||||||
|
empty_label="Select an email template...", |
||||||
|
widget=forms.Select(attrs={'class': 'form-control'}) |
||||||
|
) |
||||||
@ -0,0 +1,102 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-07-09 15:18 |
||||||
|
|
||||||
|
import django.db.models.deletion |
||||||
|
import django.utils.timezone |
||||||
|
from django.conf import settings |
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
initial = True |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.CreateModel( |
||||||
|
name='Activity', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||||
|
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||||
|
('data_access_ids', models.JSONField(default=list)), |
||||||
|
('status', models.CharField(blank=True, choices=[('NONE', 'None'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED_TOO_EXPENSIVE', 'Too expensive'), ('DECLINED_USE_SOMETHING_ELSE', 'Use something else'), ('DECLINED_OTHER', 'Declined other reason'), ('DECLINED_UNRELATED', 'Declined without significance')], default='NONE', max_length=50, null=True)), |
||||||
|
('type', models.CharField(blank=True, choices=[('MAIL', 'Mailing List'), ('SMS', 'SMS Campaign'), ('CALL', 'Call'), ('PRESS', 'Press Release')], max_length=20, null=True)), |
||||||
|
('description', models.TextField(blank=True, null=True)), |
||||||
|
('attachment_text', models.TextField(blank=True, null=True)), |
||||||
|
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'verbose_name_plural': 'Activities', |
||||||
|
'ordering': ['-creation_date'], |
||||||
|
'permissions': [('manage_events', 'Can manage events'), ('view_events', 'Can view events')], |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='EmailTemplate', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||||
|
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||||
|
('data_access_ids', models.JSONField(default=list)), |
||||||
|
('name', models.CharField(max_length=100)), |
||||||
|
('subject', models.CharField(max_length=200)), |
||||||
|
('body', models.TextField(blank=True, null=True)), |
||||||
|
('activities', models.ManyToManyField(blank=True, related_name='email_templates', to='bizdev.activity')), |
||||||
|
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'abstract': False, |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='Entity', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||||
|
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||||
|
('data_access_ids', models.JSONField(default=list)), |
||||||
|
('name', models.CharField(blank=True, max_length=200, null=True)), |
||||||
|
('address', models.CharField(blank=True, max_length=200, null=True)), |
||||||
|
('zip_code', models.CharField(blank=True, max_length=20, null=True)), |
||||||
|
('city', models.CharField(blank=True, max_length=500, null=True)), |
||||||
|
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
('official_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), |
||||||
|
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'verbose_name_plural': 'Entities', |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='Prospect', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||||
|
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||||
|
('data_access_ids', models.JSONField(default=list)), |
||||||
|
('first_name', models.CharField(blank=True, max_length=200, null=True)), |
||||||
|
('last_name', models.CharField(blank=True, max_length=200, null=True)), |
||||||
|
('email', models.EmailField(max_length=254, unique=True)), |
||||||
|
('phone', models.CharField(blank=True, max_length=25, null=True)), |
||||||
|
('name_unsure', models.BooleanField(default=False)), |
||||||
|
('source', models.CharField(blank=True, max_length=100, null=True)), |
||||||
|
('entities', models.ManyToManyField(blank=True, related_name='prospects', to='bizdev.entity')), |
||||||
|
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
('official_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), |
||||||
|
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'permissions': [('manage_prospects', 'Can manage prospects'), ('view_prospects', 'Can view prospects')], |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='activity', |
||||||
|
name='prospects', |
||||||
|
field=models.ManyToManyField(related_name='activities', to='bizdev.prospect'), |
||||||
|
), |
||||||
|
] |
||||||
@ -1,6 +1,6 @@ |
|||||||
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin |
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin |
||||||
from django.core.exceptions import PermissionDenied |
from django.core.exceptions import PermissionDenied |
||||||
|
|
||||||
class CRMAccessMixin(LoginRequiredMixin, UserPassesTestMixin): |
class bizdevAccessMixin(LoginRequiredMixin, UserPassesTestMixin): |
||||||
def test_func(self): |
def test_func(self): |
||||||
return self.request.user.groups.filter(name='CRM Manager').exists() |
return self.request.user.groups.filter(name='bizdev Manager').exists() |
||||||
@ -0,0 +1,154 @@ |
|||||||
|
from typing import Self |
||||||
|
from django.db import models |
||||||
|
from django.contrib.auth import get_user_model |
||||||
|
|
||||||
|
from django.db.models.signals import m2m_changed |
||||||
|
from django.dispatch import receiver |
||||||
|
from django.utils import timezone |
||||||
|
|
||||||
|
from sync.models import BaseModel |
||||||
|
|
||||||
|
User = get_user_model() |
||||||
|
|
||||||
|
class Status(models.TextChoices): |
||||||
|
NONE = 'NONE', 'None' |
||||||
|
CONTACTED = 'CONTACTED', 'Contacted' |
||||||
|
RESPONDED = 'RESPONDED', 'Responded' |
||||||
|
SHOULD_TEST = 'SHOULD_TEST', 'Should test' |
||||||
|
TESTING = 'TESTING', 'Testing' |
||||||
|
CUSTOMER = 'CUSTOMER', 'Customer' |
||||||
|
LOST = 'LOST', 'Lost customer' |
||||||
|
DECLINED_TOO_EXPENSIVE = 'DECLINED_TOO_EXPENSIVE', 'Too expensive' |
||||||
|
DECLINED_USE_SOMETHING_ELSE = 'DECLINED_USE_SOMETHING_ELSE', 'Use something else' |
||||||
|
DECLINED_OTHER = 'DECLINED_OTHER', 'Declined other reason' |
||||||
|
DECLINED_UNRELATED = 'DECLINED_UNRELATED', 'Declined without significance' |
||||||
|
|
||||||
|
class ActivityType(models.TextChoices): |
||||||
|
MAIL = 'MAIL', 'Mailing List' |
||||||
|
SMS = 'SMS', 'SMS Campaign' |
||||||
|
CALL = 'CALL', 'Call' |
||||||
|
PRESS = 'PRESS', 'Press Release' |
||||||
|
|
||||||
|
class Entity(BaseModel): |
||||||
|
name = models.CharField(max_length=200, null=True, blank=True) |
||||||
|
address = models.CharField(max_length=200, null=True, blank=True) |
||||||
|
zip_code = models.CharField(max_length=20, null=True, blank=True) |
||||||
|
city = models.CharField(max_length=500, null=True, blank=True) |
||||||
|
official_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) |
||||||
|
# status = models.IntegerField(default=Status.NONE, choices=Status.choices) |
||||||
|
|
||||||
|
def delete_dependencies(self): |
||||||
|
pass |
||||||
|
|
||||||
|
class Meta: |
||||||
|
verbose_name_plural = "Entities" |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return self.name |
||||||
|
|
||||||
|
class Prospect(BaseModel): |
||||||
|
first_name = models.CharField(max_length=200, null=True, blank=True) |
||||||
|
last_name = models.CharField(max_length=200, null=True, blank=True) |
||||||
|
email = models.EmailField(unique=True) |
||||||
|
phone = models.CharField(max_length=25, null=True, blank=True) |
||||||
|
name_unsure = models.BooleanField(default=False) |
||||||
|
official_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) |
||||||
|
|
||||||
|
entities = models.ManyToManyField(Entity, blank=True, related_name='prospects') |
||||||
|
source = models.CharField(max_length=100, null=True, blank=True) |
||||||
|
|
||||||
|
def delete_dependencies(self): |
||||||
|
pass |
||||||
|
|
||||||
|
class Meta: |
||||||
|
permissions = [ |
||||||
|
("manage_prospects", "Can manage prospects"), |
||||||
|
("view_prospects", "Can view prospects"), |
||||||
|
] |
||||||
|
|
||||||
|
def current_status(self): |
||||||
|
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first() |
||||||
|
if last_activity: |
||||||
|
return last_activity.status |
||||||
|
return None |
||||||
|
|
||||||
|
def entity_names(self): |
||||||
|
entity_names = [entity.name for entity in self.entities.all()] |
||||||
|
return " - ".join(entity_names) |
||||||
|
|
||||||
|
def full_name(self): |
||||||
|
return f'{self.first_name} {self.last_name}' |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return self.full_name() |
||||||
|
|
||||||
|
class Activity(BaseModel): |
||||||
|
status = models.CharField(max_length=50, default=Status.NONE, choices=Status.choices, null=True, blank=True) |
||||||
|
type = models.CharField(max_length=20, choices=ActivityType.choices, null=True, blank=True) |
||||||
|
description = models.TextField(null=True, blank=True) |
||||||
|
attachment_text = models.TextField(null=True, blank=True) |
||||||
|
prospects = models.ManyToManyField(Prospect, related_name='activities') |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
if self.status: |
||||||
|
return self.status |
||||||
|
elif self.type: |
||||||
|
return self.type |
||||||
|
else: |
||||||
|
return f'desc = {self.description}, attachment_text = {self.attachment_text}' |
||||||
|
|
||||||
|
def delete_dependencies(self): |
||||||
|
pass |
||||||
|
|
||||||
|
class Meta: |
||||||
|
verbose_name_plural = "Activities" |
||||||
|
ordering = ['-creation_date'] |
||||||
|
permissions = [ |
||||||
|
("manage_events", "Can manage events"), |
||||||
|
("view_events", "Can view events"), |
||||||
|
] |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return f"{self.get_type_display()} - {self.creation_date.date()}" |
||||||
|
|
||||||
|
def html_desc(self): |
||||||
|
fields = [field for field in [self.creation_date.strftime("%d/%m/%Y %H:%M"), self.type, self.status, self.attachment_text, self.description] if field is not None] |
||||||
|
html = '<table><tr>' |
||||||
|
for field in fields: |
||||||
|
html += f'<td style="padding:0px 5px;">{field}</td>' |
||||||
|
html += '</tr></table>' |
||||||
|
return html |
||||||
|
|
||||||
|
@receiver(m2m_changed, sender=Activity.prospects.through) |
||||||
|
def update_prospect_last_update(sender, instance, action, pk_set, **kwargs): |
||||||
|
instance.prospects.update(last_update=timezone.now()) |
||||||
|
|
||||||
|
class EmailTemplate(BaseModel): |
||||||
|
name = models.CharField(max_length=100) |
||||||
|
subject = models.CharField(max_length=200) |
||||||
|
body = models.TextField(null=True, blank=True) |
||||||
|
activities = models.ManyToManyField(Activity, blank=True, related_name='email_templates') |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return self.name |
||||||
|
|
||||||
|
# class EmailCampaign(models.Model): |
||||||
|
# event = models.OneToOneField(Event, on_delete=models.CASCADE) |
||||||
|
# subject = models.CharField(max_length=200) |
||||||
|
# content = models.TextField() |
||||||
|
# sent_at = models.DateTimeField(null=True, blank=True) |
||||||
|
|
||||||
|
# class EmailTracker(models.Model): |
||||||
|
# campaign = models.ForeignKey(EmailCampaign, on_delete=models.CASCADE) |
||||||
|
# prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE) |
||||||
|
# tracking_id = models.UUIDField(default=uuid.uuid4, editable=False) |
||||||
|
# sent = models.BooleanField(default=False) |
||||||
|
# sent_at = models.DateTimeField(null=True, blank=True) |
||||||
|
# opened = models.BooleanField(default=False) |
||||||
|
# opened_at = models.DateTimeField(null=True, blank=True) |
||||||
|
# clicked = models.BooleanField(default=False) |
||||||
|
# clicked_at = models.DateTimeField(null=True, blank=True) |
||||||
|
# error_message = models.TextField(blank=True) |
||||||
|
|
||||||
|
# class Meta: |
||||||
|
# unique_together = ['campaign', 'prospect'] |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
from rest_framework import serializers |
||||||
|
from .models import Activity, Prospect, Entity |
||||||
|
|
||||||
|
class CRMActivitySerializer(serializers.ModelSerializer): |
||||||
|
class Meta: |
||||||
|
model = Activity |
||||||
|
fields = '__all__' |
||||||
|
|
||||||
|
class CRMProspectSerializer(serializers.ModelSerializer): |
||||||
|
class Meta: |
||||||
|
model = Prospect |
||||||
|
fields = '__all__' |
||||||
|
|
||||||
|
class CRMEntitySerializer(serializers.ModelSerializer): |
||||||
|
class Meta: |
||||||
|
model = Entity |
||||||
|
fields = '__all__' |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
<!-- templates/admin/bizdev/app_index.html --> |
||||||
|
{% extends "admin/app_index.html" %} |
||||||
|
{% load i18n %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="module" style="margin-bottom: 20px;"> |
||||||
|
<!-- <h2>{% trans "Actions" %}</h2> --> |
||||||
|
<div class="form-row"> |
||||||
|
<a href="{% url 'bizdev_email_count' %}" class="button default" style="margin-right: 5px;"> |
||||||
|
{% trans "Count Users no event" %} |
||||||
|
</a> |
||||||
|
<a href="{% url 'bizdev_email_users' %}" class="button default" style="margin-right: 5px;"> |
||||||
|
{% trans "Insta send email no event" %} |
||||||
|
</a> |
||||||
|
<a href="{% url 'bizdev_email_with_tournaments_count' %}" class="button default" style="margin-right: 5px;"> |
||||||
|
{% trans "Count Users" %} |
||||||
|
</a> |
||||||
|
<a href="{% url 'email_users_with_tournaments' %}" class="button default" style="margin-right: 5px;"> |
||||||
|
{% trans "Insta send email" %} |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{{ block.super }} |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,81 @@ |
|||||||
|
{% extends "admin/base_site.html" %} |
||||||
|
{% load i18n admin_urls static admin_list %} |
||||||
|
|
||||||
|
{% block title %}Email Users{% endblock %} |
||||||
|
|
||||||
|
{% block breadcrumbs %} |
||||||
|
<div class="breadcrumbs"> |
||||||
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> |
||||||
|
› Email Users |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="module filtered"> |
||||||
|
<h2>Filter Users for Email</h2> |
||||||
|
|
||||||
|
<form method="post" action="{% url 'admin:email_users' %}"> |
||||||
|
{% csrf_token %} |
||||||
|
|
||||||
|
<div class="form-row"> |
||||||
|
<div class="field-box"> |
||||||
|
<label for="user_origin">User Origin:</label> |
||||||
|
<select name="user_origin" id="user_origin" class="vTextField"> |
||||||
|
<option value="">All Origins</option> |
||||||
|
{% for choice in user_origin_choices %} |
||||||
|
<option value="{{ choice.0 }}" {% if choice.0 == selected_origin %}selected{% endif %}> |
||||||
|
{{ choice.1 }} |
||||||
|
</option> |
||||||
|
{% endfor %} |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-row"> |
||||||
|
<div class="field-box"> |
||||||
|
<label for="has_purchase"> |
||||||
|
<input type="checkbox" name="has_purchase" id="has_purchase" value="1" |
||||||
|
{% if has_purchase %}checked{% endif %}> |
||||||
|
User has made a purchase |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-row"> |
||||||
|
<input type="submit" value="Filter Users" class="default" name="_filter"> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
{% if filtered_users %} |
||||||
|
<div class="results"> |
||||||
|
<h3>Filtered Users ({{ filtered_users|length }} found)</h3> |
||||||
|
<div class="module"> |
||||||
|
<table cellspacing="0"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Email</th> |
||||||
|
<th>Origin</th> |
||||||
|
<th>Has Purchase</th> |
||||||
|
<th>Date Joined</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for user in filtered_users %} |
||||||
|
<tr class="{% cycle 'row1' 'row2' %}"> |
||||||
|
<td>{{ user.email }}</td> |
||||||
|
<td>{{ user.get_origin_display }}</td> |
||||||
|
<td>{{ user.has_purchase|yesno:"Yes,No" }}</td> |
||||||
|
<td>{{ user.date_joined|date:"M d, Y" }}</td> |
||||||
|
</tr> |
||||||
|
{% empty %} |
||||||
|
<tr> |
||||||
|
<td colspan="4">No users found matching criteria.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
{% extends "admin/change_list.html" %} |
||||||
|
|
||||||
|
{% block object-tools-items %} |
||||||
|
{{ block.super }} |
||||||
|
<li> |
||||||
|
<a href="{% url 'admin:import_file' %}" class="addlink" style="margin-right: 5px;">Import</a> |
||||||
|
<a href="{% url 'admin:import_app_users' %}" class="addlink" style="margin-right: 5px;">Import App Users</a> |
||||||
|
<a href="{% url 'admin:cleanup' %}" class="addlink" style="margin-right: 5px;">Reset</a> |
||||||
|
</li> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block search %} |
||||||
|
|
||||||
|
{{ block.super }} |
||||||
|
|
||||||
|
<div style="margin-top: 10px; margin-bottom: 10px;"> |
||||||
|
<a href="{{ user_filter_url }}" class="button"> |
||||||
|
My Prospects |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
<!-- templates/admin/import_file.html --> |
||||||
|
{% extends "admin/base_site.html" %} |
||||||
|
{% load i18n static %} |
||||||
|
|
||||||
|
{% block title %}{% trans 'Import File' %}{% endblock %} |
||||||
|
|
||||||
|
{% block breadcrumbs %} |
||||||
|
<div class="breadcrumbs"> |
||||||
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> |
||||||
|
› {% trans 'Import File' %} |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="module"> |
||||||
|
<form method="post" enctype="multipart/form-data" novalidate> |
||||||
|
{% csrf_token %} |
||||||
|
|
||||||
|
<div class="form-row"> |
||||||
|
<div class="field-box"> |
||||||
|
{{ form.source.label_tag }} |
||||||
|
{{ form.source }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="field-box"> |
||||||
|
{{ form.file.label_tag }} |
||||||
|
{{ form.file }} |
||||||
|
{% if form.file.help_text %} |
||||||
|
<div class="help">{{ form.file.help_text }}</div> |
||||||
|
{% endif %} |
||||||
|
{% if form.file.errors %} |
||||||
|
<ul class="errorlist"> |
||||||
|
{% for error in form.file.errors %} |
||||||
|
<li>{{ error }}</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="submit-row"> |
||||||
|
<input type="submit" value="{% trans 'Import File' %}" class="default" /> |
||||||
|
<a href="{% url 'admin:index' %}" class="button cancel-link">{% trans 'Cancel' %}</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="module"> |
||||||
|
<h2>{% trans 'Instructions' %}</h2> |
||||||
|
<p>{% trans 'Select a file to import and click "Import File" to process it.' %}</p> |
||||||
|
<p>{% trans 'Supported file formats: CSV, Excel (XLSX, XLS), and Text files.' %}</p> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
{% extends "admin/base_site.html" %} |
||||||
|
{% load i18n admin_urls %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div id="content-main"> |
||||||
|
<form action="" method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
<h2>{{ title }}</h2> |
||||||
|
|
||||||
|
<p>You have selected the following prospects:</p> |
||||||
|
<ul> |
||||||
|
{% for prospect in prospects %} |
||||||
|
<li>{{ prospect.name }} ({{ prospect.email }})</li> |
||||||
|
<input type="hidden" name="_selected_action" value="{{ prospect.pk }}" /> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
|
||||||
|
<fieldset class="module aligned"> |
||||||
|
<h2>Select an email template:</h2> |
||||||
|
{{ form.as_p }} |
||||||
|
</fieldset> |
||||||
|
|
||||||
|
<div class="submit-row"> |
||||||
|
<input type="hidden" name="action" value="send_email" /> |
||||||
|
<input type="submit" name="apply" value="Send Email" class="default" /> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -1,4 +1,4 @@ |
|||||||
{% extends "crm/base.html" %} |
{% extends "bizdev/base.html" %} |
||||||
|
|
||||||
{% block content %} |
{% block content %} |
||||||
<div class="container padding-bottom"> |
<div class="container padding-bottom"> |
||||||
@ -1,4 +1,4 @@ |
|||||||
{% extends "crm/base.html" %} |
{% extends "bizdev/base.html" %} |
||||||
|
|
||||||
{% block content %} |
{% block content %} |
||||||
<div class="container mt-4"> |
<div class="container mt-4"> |
||||||
@ -1,4 +1,4 @@ |
|||||||
{% extends "crm/base.html" %} |
{% extends "bizdev/base.html" %} |
||||||
|
|
||||||
{% block head_title %}{{ first_title }}{% endblock %} |
{% block head_title %}{{ first_title }}{% endblock %} |
||||||
{% block first_title %}{{ first_title }}{% endblock %} |
{% block first_title %}{{ first_title }}{% endblock %} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
from django import template |
||||||
|
|
||||||
|
register = template.Library() |
||||||
|
|
||||||
|
@register.filter(name='is_bizdev_manager') |
||||||
|
def is_bizdev_manager(user): |
||||||
|
return user.groups.filter(name='bizdev Manager').exists() |
||||||
@ -1,7 +1,7 @@ |
|||||||
from django.urls import path |
from django.urls import path |
||||||
from . import views |
from . import views |
||||||
|
|
||||||
app_name = 'crm' |
app_name = 'bizdev' |
||||||
|
|
||||||
urlpatterns = [ |
urlpatterns = [ |
||||||
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='events'), |
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='events'), |
||||||
@ -0,0 +1,284 @@ |
|||||||
|
# views.py |
||||||
|
from django.views.generic import FormView, ListView, DetailView, CreateView, UpdateView |
||||||
|
from django.views.generic.edit import FormView, BaseUpdateView |
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin |
||||||
|
from django.contrib.auth.decorators import permission_required |
||||||
|
from django.contrib import messages |
||||||
|
from django.shortcuts import render, redirect, get_object_or_404 |
||||||
|
from django.urls import reverse_lazy |
||||||
|
from django.http import HttpResponse, HttpResponseRedirect |
||||||
|
from django.views import View |
||||||
|
from django.utils import timezone |
||||||
|
from django.contrib.sites.shortcuts import get_current_site |
||||||
|
from django.template.loader import render_to_string |
||||||
|
from django.core.mail import send_mail |
||||||
|
from django.conf import settings |
||||||
|
from django.db import IntegrityError |
||||||
|
|
||||||
|
from .models import Event, Prospect, ActivityType |
||||||
|
from .filters import ProspectFilter |
||||||
|
from .forms import CSVImportForm |
||||||
|
|
||||||
|
from .mixins import bizdevAccessMixin |
||||||
|
|
||||||
|
import csv |
||||||
|
from io import TextIOWrapper |
||||||
|
from datetime import datetime |
||||||
|
|
||||||
|
# @permission_required('bizdev.view_bizdev', raise_exception=True) |
||||||
|
# def prospect_form(request, pk=None): |
||||||
|
# # Get the prospect instance if pk is provided (edit mode) |
||||||
|
# prospect = get_object_or_404(Prospect, pk=pk) if pk else None |
||||||
|
|
||||||
|
# if request.method == 'POST': |
||||||
|
# form = ProspectForm(request.POST, instance=prospect) |
||||||
|
# if form.is_valid(): |
||||||
|
# prospect = form.save(commit=False) |
||||||
|
# if not pk: # New prospect |
||||||
|
# prospect.created_by = request.user |
||||||
|
# prospect.modified_by = request.user |
||||||
|
# prospect.save() |
||||||
|
|
||||||
|
# action = 'updated' if pk else 'added' |
||||||
|
# messages.success(request, |
||||||
|
# f'Prospect {prospect.entity_name} has been {action} successfully!') |
||||||
|
# return redirect('bizdev:events') |
||||||
|
# else: |
||||||
|
# form = ProspectForm(instance=prospect) |
||||||
|
|
||||||
|
# context = { |
||||||
|
# 'form': form, |
||||||
|
# 'is_edit': prospect is not None, |
||||||
|
# 'first_title': prospect.entity_name if prospect else 'Add Prospect', |
||||||
|
# 'second_title': prospect.full_name() if prospect else None |
||||||
|
# } |
||||||
|
# return render(request, 'bizdev/prospect_form.html', context) |
||||||
|
|
||||||
|
# # @permission_required('bizdev.view_bizdev', raise_exception=True) |
||||||
|
# # def add_prospect(request): |
||||||
|
# # if request.method == 'POST': |
||||||
|
# # entity_name = request.POST.get('entity_name') |
||||||
|
# # first_name = request.POST.get('first_name') |
||||||
|
# # last_name = request.POST.get('last_name') |
||||||
|
# # email = request.POST.get('email') |
||||||
|
# # phone = request.POST.get('phone') |
||||||
|
# # address = request.POST.get('address') |
||||||
|
# # zip_code = request.POST.get('zip_code') |
||||||
|
# # city = request.POST.get('city') |
||||||
|
# # # region = request.POST.get('region') |
||||||
|
|
||||||
|
# # try: |
||||||
|
# # prospect = Prospect.objects.create( |
||||||
|
# # entity_name=entity_name, |
||||||
|
# # first_name=first_name, |
||||||
|
# # last_name=last_name, |
||||||
|
# # email=email, |
||||||
|
# # phone=phone, |
||||||
|
# # address=address, |
||||||
|
# # zip_code=zip_code, |
||||||
|
# # city=city, |
||||||
|
# # # region=region, |
||||||
|
# # created_by=request.user, |
||||||
|
# # modified_by=request.user |
||||||
|
# # ) |
||||||
|
# # messages.success(request, f'Prospect {name} has been added successfully!') |
||||||
|
# # return redirect('bizdev:events') # or wherever you want to redirect after success |
||||||
|
# # except Exception as e: |
||||||
|
# # messages.error(request, f'Error adding prospect: {str(e)}') |
||||||
|
|
||||||
|
# # return render(request, 'bizdev/add_prospect.html') |
||||||
|
|
||||||
|
# class EventCreateView(bizdevAccessMixin, CreateView): |
||||||
|
# model = Event |
||||||
|
# form_class = EventForm |
||||||
|
# template_name = 'bizdev/event_form.html' |
||||||
|
# success_url = reverse_lazy('bizdev:planned_events') |
||||||
|
|
||||||
|
# def get_initial(self): |
||||||
|
# initial = super().get_initial() |
||||||
|
# prospect_id = self.kwargs.get('prospect_id') |
||||||
|
# if prospect_id: |
||||||
|
# initial['prospects'] = [prospect_id] |
||||||
|
# return initial |
||||||
|
|
||||||
|
# def form_valid(self, form): |
||||||
|
# form.instance.created_by = self.request.user |
||||||
|
# form.instance.modified_by = self.request.user |
||||||
|
# return super().form_valid(form) |
||||||
|
|
||||||
|
# class EditEventView(bizdevAccessMixin, UpdateView): |
||||||
|
# model = Event |
||||||
|
# form_class = EventForm |
||||||
|
# template_name = 'bizdev/event_form.html' |
||||||
|
# success_url = reverse_lazy('bizdev:planned_events') |
||||||
|
|
||||||
|
# def form_valid(self, form): |
||||||
|
# form.instance.modified_by = self.request.user |
||||||
|
# response = super().form_valid(form) |
||||||
|
# messages.success(self.request, 'Event updated successfully!') |
||||||
|
# return response |
||||||
|
|
||||||
|
# class StartEventView(bizdevAccessMixin, BaseUpdateView): |
||||||
|
# model = Event |
||||||
|
# http_method_names = ['post', 'get'] |
||||||
|
|
||||||
|
# def get(self, request, *args, **kwargs): |
||||||
|
# return self.post(request, *args, **kwargs) |
||||||
|
|
||||||
|
# def post(self, request, *args, **kwargs): |
||||||
|
# event = get_object_or_404(Event, pk=kwargs['pk'], status='PLANNED') |
||||||
|
# event.status = 'ACTIVE' |
||||||
|
# event.save() |
||||||
|
|
||||||
|
# if event.type == 'MAIL': |
||||||
|
# return HttpResponseRedirect( |
||||||
|
# reverse_lazy('bizdev:setup_email_campaign', kwargs={'event_id': event.id}) |
||||||
|
# ) |
||||||
|
# elif event.type == 'SMS': |
||||||
|
# return HttpResponseRedirect( |
||||||
|
# reverse_lazy('bizdev:setup_sms_campaign', kwargs={'event_id': event.id}) |
||||||
|
# ) |
||||||
|
# elif event.type == 'PRESS': |
||||||
|
# return HttpResponseRedirect( |
||||||
|
# reverse_lazy('bizdev:setup_press_release', kwargs={'event_id': event.id}) |
||||||
|
# ) |
||||||
|
|
||||||
|
# messages.success(request, 'Event started successfully!') |
||||||
|
# return HttpResponseRedirect(reverse_lazy('bizdev:planned_events')) |
||||||
|
|
||||||
|
# class EventListView(bizdevAccessMixin, ListView): |
||||||
|
# model = Event |
||||||
|
# template_name = 'bizdev/events.html' |
||||||
|
# context_object_name = 'events' # We won't use this since we're providing custom context |
||||||
|
|
||||||
|
# def get_context_data(self, **kwargs): |
||||||
|
# context = super().get_context_data(**kwargs) |
||||||
|
# context['planned_events'] = Event.objects.filter( |
||||||
|
# status='PLANNED' |
||||||
|
# ).order_by('date') |
||||||
|
# context['completed_events'] = Event.objects.filter( |
||||||
|
# status='COMPLETED' |
||||||
|
# ).order_by('-date') |
||||||
|
# return context |
||||||
|
|
||||||
|
# class ProspectListView(bizdevAccessMixin, ListView): |
||||||
|
# model = Prospect |
||||||
|
# template_name = 'bizdev/prospect_list.html' |
||||||
|
# context_object_name = 'prospects' |
||||||
|
# filterset_class = ProspectFilter |
||||||
|
|
||||||
|
# def get_queryset(self): |
||||||
|
# return super().get_queryset().prefetch_related('prospectstatus_set__status') |
||||||
|
|
||||||
|
# def get_context_data(self, **kwargs): |
||||||
|
# context = super().get_context_data(**kwargs) |
||||||
|
# context['filter'] = self.filterset_class( |
||||||
|
# self.request.GET, |
||||||
|
# queryset=self.get_queryset() |
||||||
|
# ) |
||||||
|
# return context |
||||||
|
|
||||||
|
# class CSVImportView(bizdevAccessMixin, FormView): |
||||||
|
# template_name = 'bizdev/csv_import.html' |
||||||
|
# form_class = CSVImportForm |
||||||
|
# success_url = reverse_lazy('prospect-list') |
||||||
|
|
||||||
|
# def form_valid(self, form): |
||||||
|
# csv_file = TextIOWrapper( |
||||||
|
# form.cleaned_data['csv_file'].file, |
||||||
|
# encoding='utf-8-sig' # Handle potential BOM in CSV |
||||||
|
# ) |
||||||
|
# reader = csv.reader(csv_file, delimiter=';') # Using semicolon delimiter |
||||||
|
|
||||||
|
# # Skip header if exists |
||||||
|
# next(reader, None) |
||||||
|
|
||||||
|
# created_count = 0 |
||||||
|
# updated_count = 0 |
||||||
|
# error_count = 0 |
||||||
|
|
||||||
|
# for row in reader: |
||||||
|
# try: |
||||||
|
# if len(row) < 10: # Ensure we have enough columns |
||||||
|
# continue |
||||||
|
|
||||||
|
# # Extract data from correct columns |
||||||
|
# entity_name = row[0].strip() |
||||||
|
# last_name = row[1].strip() |
||||||
|
# first_name = row[2].strip() |
||||||
|
# email = row[3].strip() |
||||||
|
# phone = row[4].strip() |
||||||
|
# zip_code = row[8].strip() |
||||||
|
# city = row[9].strip() |
||||||
|
|
||||||
|
# # Try to update existing prospect or create new one |
||||||
|
# prospect, created = Prospect.objects.update_or_create( |
||||||
|
# email=email, # Use email as unique identifier |
||||||
|
# defaults={ |
||||||
|
# 'entity_name': entity_name, |
||||||
|
# 'first_name': first_name, |
||||||
|
# 'last_name': last_name, |
||||||
|
# 'phone': phone, |
||||||
|
# 'zip_code': zip_code, |
||||||
|
# 'city': city, |
||||||
|
# 'modified_by': self.request.user, |
||||||
|
# } |
||||||
|
# ) |
||||||
|
|
||||||
|
# if created: |
||||||
|
# prospect.created_by = self.request.user |
||||||
|
# prospect.save() |
||||||
|
# created_count += 1 |
||||||
|
# else: |
||||||
|
# updated_count += 1 |
||||||
|
|
||||||
|
# except Exception as e: |
||||||
|
# error_count += 1 |
||||||
|
# messages.error( |
||||||
|
# self.request, |
||||||
|
# f"Error processing row with email {email}: {str(e)}" |
||||||
|
# ) |
||||||
|
|
||||||
|
# # Add success message |
||||||
|
# messages.success( |
||||||
|
# self.request, |
||||||
|
# f"Import completed: {created_count} created, {updated_count} updated, {error_count} errors" |
||||||
|
# ) |
||||||
|
|
||||||
|
# return super().form_valid(form) |
||||||
|
|
||||||
|
# class SendBulkEmailView(bizdevAccessMixin, FormView): |
||||||
|
# template_name = 'bizdev/send_bulk_email.html' |
||||||
|
# form_class = BulkEmailForm |
||||||
|
# success_url = reverse_lazy('bizdev:prospect-list') |
||||||
|
|
||||||
|
# def form_valid(self, form): |
||||||
|
# prospects = form.cleaned_data['prospects'] |
||||||
|
# subject = form.cleaned_data['subject'] |
||||||
|
# content = form.cleaned_data['content'] |
||||||
|
|
||||||
|
# # Create Event for this email campaign |
||||||
|
# event = Event.objects.create( |
||||||
|
# date=datetime.now(), |
||||||
|
# type=EventType.MAILING, |
||||||
|
# description=f"Bulk email: {subject}", |
||||||
|
# status='COMPLETED', |
||||||
|
# created_by=self.request.user, |
||||||
|
# modified_by=self.request.user |
||||||
|
# ) |
||||||
|
# event.prospects.set(prospects) |
||||||
|
|
||||||
|
# # Send emails |
||||||
|
# success_count, error_count = send_bulk_email( |
||||||
|
# subject=subject, |
||||||
|
# content=content, |
||||||
|
# prospects=prospects |
||||||
|
# ) |
||||||
|
|
||||||
|
# # Show result message |
||||||
|
# messages.success( |
||||||
|
# self.request, |
||||||
|
# f"Sent {success_count} emails successfully. {error_count} failed." |
||||||
|
# ) |
||||||
|
|
||||||
|
# return super().form_valid(form) |
||||||
@ -1 +0,0 @@ |
|||||||
This is a django customer relationship managemement (CRM) app. |
|
||||||
@ -1,96 +0,0 @@ |
|||||||
from django.contrib import admin |
|
||||||
from django.utils.html import format_html |
|
||||||
from .models import ( |
|
||||||
Prospect, |
|
||||||
Status, |
|
||||||
ProspectStatus, |
|
||||||
Event, |
|
||||||
EmailCampaign, |
|
||||||
EmailTracker |
|
||||||
) |
|
||||||
|
|
||||||
@admin.register(Prospect) |
|
||||||
class ProspectAdmin(admin.ModelAdmin): |
|
||||||
list_display = ('entity_name', 'first_name', 'last_name', 'email', 'address', 'zip_code', 'city', 'created_at') |
|
||||||
list_filter = ('zip_code', 'created_at') |
|
||||||
search_fields = ('entity_name', 'first_name', 'last_name', 'email', 'zip_code', 'city') |
|
||||||
filter_horizontal = ('users',) |
|
||||||
date_hierarchy = 'created_at' |
|
||||||
|
|
||||||
@admin.register(Status) |
|
||||||
class StatusAdmin(admin.ModelAdmin): |
|
||||||
list_display = ('name', 'created_at') |
|
||||||
search_fields = ('name',) |
|
||||||
|
|
||||||
@admin.register(ProspectStatus) |
|
||||||
class ProspectStatusAdmin(admin.ModelAdmin): |
|
||||||
list_display = ('prospect', 'status', 'created_at') |
|
||||||
list_filter = ('status', 'created_at') |
|
||||||
search_fields = ('prospect__name', 'prospect__email') |
|
||||||
date_hierarchy = 'created_at' |
|
||||||
|
|
||||||
@admin.register(Event) |
|
||||||
class EventAdmin(admin.ModelAdmin): |
|
||||||
list_display = ('get_event_display', 'type', 'date', 'status', 'created_at') |
|
||||||
list_filter = ('type', 'status', 'date') |
|
||||||
search_fields = ('description',) |
|
||||||
filter_horizontal = ('prospects',) |
|
||||||
date_hierarchy = 'date' |
|
||||||
|
|
||||||
def get_event_display(self, obj): |
|
||||||
return str(obj) |
|
||||||
get_event_display.short_description = 'Event' |
|
||||||
|
|
||||||
@admin.register(EmailCampaign) |
|
||||||
class EmailCampaignAdmin(admin.ModelAdmin): |
|
||||||
list_display = ('subject', 'event', 'sent_at') |
|
||||||
list_filter = ('sent_at',) |
|
||||||
search_fields = ('subject', 'content') |
|
||||||
date_hierarchy = 'sent_at' |
|
||||||
readonly_fields = ('sent_at',) |
|
||||||
|
|
||||||
@admin.register(EmailTracker) |
|
||||||
class EmailTrackerAdmin(admin.ModelAdmin): |
|
||||||
list_display = ( |
|
||||||
'campaign', |
|
||||||
'prospect', |
|
||||||
'tracking_id', |
|
||||||
'sent_status', |
|
||||||
'opened_status', |
|
||||||
'clicked_status' |
|
||||||
) |
|
||||||
list_filter = ('sent', 'opened', 'clicked') |
|
||||||
search_fields = ( |
|
||||||
'prospect__name', |
|
||||||
'prospect__email', |
|
||||||
'campaign__subject' |
|
||||||
) |
|
||||||
readonly_fields = ( |
|
||||||
'tracking_id', 'sent', 'sent_at', |
|
||||||
'opened', 'opened_at', |
|
||||||
'clicked', 'clicked_at' |
|
||||||
) |
|
||||||
date_hierarchy = 'sent_at' |
|
||||||
|
|
||||||
def sent_status(self, obj): |
|
||||||
return self._get_status_html(obj.sent, obj.sent_at) |
|
||||||
sent_status.short_description = 'Sent' |
|
||||||
sent_status.allow_tags = True |
|
||||||
|
|
||||||
def opened_status(self, obj): |
|
||||||
return self._get_status_html(obj.opened, obj.opened_at) |
|
||||||
opened_status.short_description = 'Opened' |
|
||||||
opened_status.allow_tags = True |
|
||||||
|
|
||||||
def clicked_status(self, obj): |
|
||||||
return self._get_status_html(obj.clicked, obj.clicked_at) |
|
||||||
clicked_status.short_description = 'Clicked' |
|
||||||
clicked_status.allow_tags = True |
|
||||||
|
|
||||||
def _get_status_html(self, status, date): |
|
||||||
if status: |
|
||||||
return format_html( |
|
||||||
'<span style="color: green;">✓</span> {}', |
|
||||||
date.strftime('%Y-%m-%d %H:%M') if date else '' |
|
||||||
) |
|
||||||
return format_html('<span style="color: red;">✗</span>') |
|
||||||
@ -1,23 +0,0 @@ |
|||||||
import django_filters |
|
||||||
from django.db.models import Q |
|
||||||
|
|
||||||
from .models import Event, Status, Prospect |
|
||||||
|
|
||||||
|
|
||||||
class ProspectFilter(django_filters.FilterSet): |
|
||||||
zip_code = django_filters.CharFilter(lookup_expr='istartswith', label='Code postal') |
|
||||||
events = django_filters.ModelMultipleChoiceFilter( |
|
||||||
queryset=Event.objects.all(), |
|
||||||
field_name='events', |
|
||||||
) |
|
||||||
city = django_filters.CharFilter(lookup_expr='icontains', label='Ville') |
|
||||||
name = django_filters.CharFilter(method='filter_name', label='Nom') |
|
||||||
|
|
||||||
def filter_name(self, queryset, name, value): |
|
||||||
return queryset.filter( |
|
||||||
Q(first_name__icontains=value) | Q(last_name__icontains=value) | Q(entity_name__icontains=value) |
|
||||||
) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
model = Prospect |
|
||||||
fields = ['name', 'city', 'events', 'zip_code'] |
|
||||||
@ -1,46 +0,0 @@ |
|||||||
from django import forms |
|
||||||
from .models import Prospect, Event |
|
||||||
import datetime |
|
||||||
|
|
||||||
class SmallTextArea(forms.Textarea): |
|
||||||
def __init__(self, *args, **kwargs): |
|
||||||
kwargs.setdefault('attrs', {}) |
|
||||||
kwargs['attrs'].update({ |
|
||||||
'rows': 2, |
|
||||||
'cols': 100, |
|
||||||
'style': 'height: 80px; width: 800px;' |
|
||||||
}) |
|
||||||
super().__init__(*args, **kwargs) |
|
||||||
|
|
||||||
class ProspectForm(forms.ModelForm): |
|
||||||
class Meta: |
|
||||||
model = Prospect |
|
||||||
fields = ['entity_name', 'first_name', 'last_name', 'email', |
|
||||||
'phone', 'address', 'zip_code', 'city'] |
|
||||||
|
|
||||||
class BulkEmailForm(forms.Form): |
|
||||||
prospects = forms.ModelMultipleChoiceField( |
|
||||||
queryset=Prospect.objects.all(), |
|
||||||
widget=forms.CheckboxSelectMultiple |
|
||||||
) |
|
||||||
subject = forms.CharField(max_length=200) |
|
||||||
content = forms.CharField(widget=forms.Textarea) |
|
||||||
|
|
||||||
class EventForm(forms.ModelForm): |
|
||||||
prospects = forms.ModelMultipleChoiceField( |
|
||||||
queryset=Prospect.objects.all(), |
|
||||||
widget=forms.SelectMultiple(attrs={'class': 'select2'}), |
|
||||||
required=False |
|
||||||
) |
|
||||||
description = forms.CharField(widget=SmallTextArea) |
|
||||||
attachment_text = forms.CharField(widget=SmallTextArea) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
model = Event |
|
||||||
fields = ['date', 'type', 'description', 'attachment_text', 'prospects', 'status'] |
|
||||||
widgets = { |
|
||||||
'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}), |
|
||||||
} |
|
||||||
|
|
||||||
class CSVImportForm(forms.Form): |
|
||||||
csv_file = forms.FileField() |
|
||||||
@ -1,94 +0,0 @@ |
|||||||
# Generated by Django 4.2.11 on 2024-12-08 15:10 |
|
||||||
|
|
||||||
from django.conf import settings |
|
||||||
from django.db import migrations, models |
|
||||||
import django.db.models.deletion |
|
||||||
import uuid |
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration): |
|
||||||
|
|
||||||
initial = True |
|
||||||
|
|
||||||
dependencies = [ |
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
|
||||||
] |
|
||||||
|
|
||||||
operations = [ |
|
||||||
migrations.CreateModel( |
|
||||||
name='Prospect', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('email', models.EmailField(max_length=254, unique=True)), |
|
||||||
('name', models.CharField(max_length=200)), |
|
||||||
('region', models.CharField(max_length=100)), |
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)), |
|
||||||
('users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), |
|
||||||
], |
|
||||||
), |
|
||||||
migrations.CreateModel( |
|
||||||
name='Status', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('name', models.CharField(max_length=100, unique=True)), |
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)), |
|
||||||
], |
|
||||||
), |
|
||||||
migrations.CreateModel( |
|
||||||
name='ProspectStatus', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)), |
|
||||||
('prospect', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.prospect')), |
|
||||||
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='crm.status')), |
|
||||||
], |
|
||||||
options={ |
|
||||||
'ordering': ['-created_at'], |
|
||||||
}, |
|
||||||
), |
|
||||||
migrations.CreateModel( |
|
||||||
name='Event', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('date', models.DateTimeField()), |
|
||||||
('type', models.CharField(choices=[('MAIL', 'Mailing List'), ('SMS', 'SMS Campaign'), ('PRESS', 'Press Release')], max_length=10)), |
|
||||||
('description', models.TextField()), |
|
||||||
('attachment_text', models.TextField(blank=True)), |
|
||||||
('status', models.CharField(choices=[('PLANNED', 'Planned'), ('ACTIVE', 'Active'), ('COMPLETED', 'Completed')], max_length=20)), |
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)), |
|
||||||
('prospects', models.ManyToManyField(related_name='events', to='crm.prospect')), |
|
||||||
], |
|
||||||
options={ |
|
||||||
'ordering': ['-created_at'], |
|
||||||
}, |
|
||||||
), |
|
||||||
migrations.CreateModel( |
|
||||||
name='EmailCampaign', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('subject', models.CharField(max_length=200)), |
|
||||||
('content', models.TextField()), |
|
||||||
('sent_at', models.DateTimeField(blank=True, null=True)), |
|
||||||
('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='crm.event')), |
|
||||||
], |
|
||||||
), |
|
||||||
migrations.CreateModel( |
|
||||||
name='EmailTracker', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('tracking_id', models.UUIDField(default=uuid.uuid4, editable=False)), |
|
||||||
('sent', models.BooleanField(default=False)), |
|
||||||
('sent_at', models.DateTimeField(blank=True, null=True)), |
|
||||||
('opened', models.BooleanField(default=False)), |
|
||||||
('opened_at', models.DateTimeField(blank=True, null=True)), |
|
||||||
('clicked', models.BooleanField(default=False)), |
|
||||||
('clicked_at', models.DateTimeField(blank=True, null=True)), |
|
||||||
('error_message', models.TextField(blank=True)), |
|
||||||
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.emailcampaign')), |
|
||||||
('prospect', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.prospect')), |
|
||||||
], |
|
||||||
options={ |
|
||||||
'unique_together': {('campaign', 'prospect')}, |
|
||||||
}, |
|
||||||
), |
|
||||||
] |
|
||||||
@ -1,60 +0,0 @@ |
|||||||
# Generated by Django 4.2.11 on 2024-12-08 20:58 |
|
||||||
|
|
||||||
from django.conf import settings |
|
||||||
from django.db import migrations, models |
|
||||||
import django.db.models.deletion |
|
||||||
import django.utils.timezone |
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration): |
|
||||||
|
|
||||||
dependencies = [ |
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
|
||||||
('crm', '0001_initial'), |
|
||||||
] |
|
||||||
|
|
||||||
operations = [ |
|
||||||
migrations.AlterModelOptions( |
|
||||||
name='event', |
|
||||||
options={'ordering': ['-created_at'], 'permissions': [('manage_events', 'Can manage events'), ('view_events', 'Can view events')]}, |
|
||||||
), |
|
||||||
migrations.AlterModelOptions( |
|
||||||
name='prospect', |
|
||||||
options={'permissions': [('manage_prospects', 'Can manage prospects'), ('view_prospects', 'Can view prospects')]}, |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='event', |
|
||||||
name='created_by', |
|
||||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_events', to=settings.AUTH_USER_MODEL), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='event', |
|
||||||
name='modified_at', |
|
||||||
field=models.DateTimeField(auto_now=True), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='event', |
|
||||||
name='modified_by', |
|
||||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_events', to=settings.AUTH_USER_MODEL), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='created_by', |
|
||||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_prospects', to=settings.AUTH_USER_MODEL), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='modified_at', |
|
||||||
field=models.DateTimeField(auto_now=True), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='modified_by', |
|
||||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_prospects', to=settings.AUTH_USER_MODEL), |
|
||||||
), |
|
||||||
migrations.AlterField( |
|
||||||
model_name='event', |
|
||||||
name='date', |
|
||||||
field=models.DateTimeField(default=django.utils.timezone.now), |
|
||||||
), |
|
||||||
] |
|
||||||
@ -1,32 +0,0 @@ |
|||||||
# Generated by Django 5.1 on 2024-12-16 15:43 |
|
||||||
|
|
||||||
from django.db import migrations, models |
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration): |
|
||||||
|
|
||||||
dependencies = [ |
|
||||||
('crm', '0002_alter_event_options_alter_prospect_options_and_more'), |
|
||||||
] |
|
||||||
|
|
||||||
operations = [ |
|
||||||
migrations.RemoveField( |
|
||||||
model_name='prospect', |
|
||||||
name='region', |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='address', |
|
||||||
field=models.CharField(blank=True, max_length=200, null=True), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='city', |
|
||||||
field=models.CharField(blank=True, max_length=500, null=True), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='zip_code', |
|
||||||
field=models.CharField(blank=True, max_length=20, null=True), |
|
||||||
), |
|
||||||
] |
|
||||||
@ -1,32 +0,0 @@ |
|||||||
# Generated by Django 5.1 on 2024-12-16 16:24 |
|
||||||
|
|
||||||
from django.db import migrations, models |
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration): |
|
||||||
|
|
||||||
dependencies = [ |
|
||||||
('crm', '0003_remove_prospect_region_prospect_address_and_more'), |
|
||||||
] |
|
||||||
|
|
||||||
operations = [ |
|
||||||
migrations.RemoveField( |
|
||||||
model_name='prospect', |
|
||||||
name='name', |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='entity_name', |
|
||||||
field=models.CharField(blank=True, max_length=200, null=True), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='first_name', |
|
||||||
field=models.CharField(blank=True, max_length=200, null=True), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='last_name', |
|
||||||
field=models.CharField(blank=True, max_length=200, null=True), |
|
||||||
), |
|
||||||
] |
|
||||||
@ -1,18 +0,0 @@ |
|||||||
# Generated by Django 5.1 on 2024-12-16 16:49 |
|
||||||
|
|
||||||
from django.db import migrations, models |
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration): |
|
||||||
|
|
||||||
dependencies = [ |
|
||||||
('crm', '0004_remove_prospect_name_prospect_entity_name_and_more'), |
|
||||||
] |
|
||||||
|
|
||||||
operations = [ |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='phone', |
|
||||||
field=models.CharField(blank=True, max_length=25, null=True), |
|
||||||
), |
|
||||||
] |
|
||||||
@ -1,120 +0,0 @@ |
|||||||
from django.db import models |
|
||||||
from django.contrib.auth import get_user_model |
|
||||||
from django.utils import timezone |
|
||||||
import uuid |
|
||||||
|
|
||||||
User = get_user_model() |
|
||||||
|
|
||||||
class EventType(models.TextChoices): |
|
||||||
MAILING = 'MAIL', 'Mailing List' |
|
||||||
SMS = 'SMS', 'SMS Campaign' |
|
||||||
PRESS = 'PRESS', 'Press Release' |
|
||||||
|
|
||||||
class Prospect(models.Model): |
|
||||||
email = models.EmailField(unique=True) |
|
||||||
entity_name = models.CharField(max_length=200, null=True, blank=True) |
|
||||||
first_name = models.CharField(max_length=200, null=True, blank=True) |
|
||||||
last_name = models.CharField(max_length=200, null=True, blank=True) |
|
||||||
address = models.CharField(max_length=200, null=True, blank=True) |
|
||||||
zip_code = models.CharField(max_length=20, null=True, blank=True) |
|
||||||
city = models.CharField(max_length=500, null=True, blank=True) |
|
||||||
phone = models.CharField(max_length=25, null=True, blank=True) |
|
||||||
users = models.ManyToManyField(get_user_model(), blank=True) |
|
||||||
created_at = models.DateTimeField(auto_now_add=True) |
|
||||||
created_by = models.ForeignKey( |
|
||||||
User, |
|
||||||
on_delete=models.SET_NULL, |
|
||||||
null=True, |
|
||||||
related_name='created_prospects' |
|
||||||
) |
|
||||||
modified_by = models.ForeignKey( |
|
||||||
User, |
|
||||||
on_delete=models.SET_NULL, |
|
||||||
null=True, |
|
||||||
related_name='modified_prospects' |
|
||||||
) |
|
||||||
modified_at = models.DateTimeField(auto_now=True) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
permissions = [ |
|
||||||
("manage_prospects", "Can manage prospects"), |
|
||||||
("view_prospects", "Can view prospects"), |
|
||||||
] |
|
||||||
|
|
||||||
def full_name(self): |
|
||||||
return f'{self.first_name} {self.last_name}' |
|
||||||
|
|
||||||
def __str__(self): |
|
||||||
return ' - '.join(filter(None, [self.entity_name, self.first_name, self.last_name, f"({self.email})"])) |
|
||||||
|
|
||||||
class Status(models.Model): |
|
||||||
name = models.CharField(max_length=100, unique=True) |
|
||||||
created_at = models.DateTimeField(auto_now_add=True) |
|
||||||
|
|
||||||
def __str__(self): |
|
||||||
return self.name |
|
||||||
|
|
||||||
class ProspectStatus(models.Model): |
|
||||||
prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE) |
|
||||||
status = models.ForeignKey(Status, on_delete=models.PROTECT) |
|
||||||
created_at = models.DateTimeField(auto_now_add=True) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
ordering = ['-created_at'] |
|
||||||
|
|
||||||
class Event(models.Model): |
|
||||||
date = models.DateTimeField(default=timezone.now) |
|
||||||
type = models.CharField(max_length=10, choices=EventType.choices) |
|
||||||
description = models.TextField() |
|
||||||
attachment_text = models.TextField(blank=True) |
|
||||||
prospects = models.ManyToManyField(Prospect, related_name='events') |
|
||||||
status = models.CharField(max_length=20, choices=[ |
|
||||||
('PLANNED', 'Planned'), |
|
||||||
('ACTIVE', 'Active'), |
|
||||||
('COMPLETED', 'Completed'), |
|
||||||
]) |
|
||||||
created_at = models.DateTimeField(auto_now_add=True) |
|
||||||
created_by = models.ForeignKey( |
|
||||||
User, |
|
||||||
on_delete=models.SET_NULL, |
|
||||||
null=True, |
|
||||||
related_name='created_events' |
|
||||||
) |
|
||||||
modified_by = models.ForeignKey( |
|
||||||
User, |
|
||||||
on_delete=models.SET_NULL, |
|
||||||
null=True, |
|
||||||
related_name='modified_events' |
|
||||||
) |
|
||||||
modified_at = models.DateTimeField(auto_now=True) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
ordering = ['-created_at'] |
|
||||||
permissions = [ |
|
||||||
("manage_events", "Can manage events"), |
|
||||||
("view_events", "Can view events"), |
|
||||||
] |
|
||||||
|
|
||||||
def __str__(self): |
|
||||||
return f"{self.get_type_display()} - {self.date.date()}" |
|
||||||
|
|
||||||
class EmailCampaign(models.Model): |
|
||||||
event = models.OneToOneField(Event, on_delete=models.CASCADE) |
|
||||||
subject = models.CharField(max_length=200) |
|
||||||
content = models.TextField() |
|
||||||
sent_at = models.DateTimeField(null=True, blank=True) |
|
||||||
|
|
||||||
class EmailTracker(models.Model): |
|
||||||
campaign = models.ForeignKey(EmailCampaign, on_delete=models.CASCADE) |
|
||||||
prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE) |
|
||||||
tracking_id = models.UUIDField(default=uuid.uuid4, editable=False) |
|
||||||
sent = models.BooleanField(default=False) |
|
||||||
sent_at = models.DateTimeField(null=True, blank=True) |
|
||||||
opened = models.BooleanField(default=False) |
|
||||||
opened_at = models.DateTimeField(null=True, blank=True) |
|
||||||
clicked = models.BooleanField(default=False) |
|
||||||
clicked_at = models.DateTimeField(null=True, blank=True) |
|
||||||
error_message = models.TextField(blank=True) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
unique_together = ['campaign', 'prospect'] |
|
||||||
@ -1,7 +0,0 @@ |
|||||||
from django import template |
|
||||||
|
|
||||||
register = template.Library() |
|
||||||
|
|
||||||
@register.filter(name='is_crm_manager') |
|
||||||
def is_crm_manager(user): |
|
||||||
return user.groups.filter(name='CRM Manager').exists() |
|
||||||
@ -1,284 +0,0 @@ |
|||||||
# views.py |
|
||||||
from django.views.generic import FormView, ListView, DetailView, CreateView, UpdateView |
|
||||||
from django.views.generic.edit import FormView, BaseUpdateView |
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin |
|
||||||
from django.contrib.auth.decorators import permission_required |
|
||||||
from django.contrib import messages |
|
||||||
from django.shortcuts import render, redirect, get_object_or_404 |
|
||||||
from django.urls import reverse_lazy |
|
||||||
from django.http import HttpResponse, HttpResponseRedirect |
|
||||||
from django.views import View |
|
||||||
from django.utils import timezone |
|
||||||
from django.contrib.sites.shortcuts import get_current_site |
|
||||||
from django.template.loader import render_to_string |
|
||||||
from django.core.mail import send_mail |
|
||||||
from django.conf import settings |
|
||||||
from django.db import IntegrityError |
|
||||||
|
|
||||||
from .models import Event, Prospect, EmailTracker, EmailCampaign, EventType |
|
||||||
from .filters import ProspectFilter |
|
||||||
from .forms import ProspectForm, CSVImportForm, BulkEmailForm, EventForm |
|
||||||
|
|
||||||
from .mixins import CRMAccessMixin |
|
||||||
|
|
||||||
import csv |
|
||||||
from io import TextIOWrapper |
|
||||||
from datetime import datetime |
|
||||||
|
|
||||||
@permission_required('crm.view_crm', raise_exception=True) |
|
||||||
def prospect_form(request, pk=None): |
|
||||||
# Get the prospect instance if pk is provided (edit mode) |
|
||||||
prospect = get_object_or_404(Prospect, pk=pk) if pk else None |
|
||||||
|
|
||||||
if request.method == 'POST': |
|
||||||
form = ProspectForm(request.POST, instance=prospect) |
|
||||||
if form.is_valid(): |
|
||||||
prospect = form.save(commit=False) |
|
||||||
if not pk: # New prospect |
|
||||||
prospect.created_by = request.user |
|
||||||
prospect.modified_by = request.user |
|
||||||
prospect.save() |
|
||||||
|
|
||||||
action = 'updated' if pk else 'added' |
|
||||||
messages.success(request, |
|
||||||
f'Prospect {prospect.entity_name} has been {action} successfully!') |
|
||||||
return redirect('crm:events') |
|
||||||
else: |
|
||||||
form = ProspectForm(instance=prospect) |
|
||||||
|
|
||||||
context = { |
|
||||||
'form': form, |
|
||||||
'is_edit': prospect is not None, |
|
||||||
'first_title': prospect.entity_name if prospect else 'Add Prospect', |
|
||||||
'second_title': prospect.full_name() if prospect else None |
|
||||||
} |
|
||||||
return render(request, 'crm/prospect_form.html', context) |
|
||||||
|
|
||||||
# @permission_required('crm.view_crm', raise_exception=True) |
|
||||||
# def add_prospect(request): |
|
||||||
# if request.method == 'POST': |
|
||||||
# entity_name = request.POST.get('entity_name') |
|
||||||
# first_name = request.POST.get('first_name') |
|
||||||
# last_name = request.POST.get('last_name') |
|
||||||
# email = request.POST.get('email') |
|
||||||
# phone = request.POST.get('phone') |
|
||||||
# address = request.POST.get('address') |
|
||||||
# zip_code = request.POST.get('zip_code') |
|
||||||
# city = request.POST.get('city') |
|
||||||
# # region = request.POST.get('region') |
|
||||||
|
|
||||||
# try: |
|
||||||
# prospect = Prospect.objects.create( |
|
||||||
# entity_name=entity_name, |
|
||||||
# first_name=first_name, |
|
||||||
# last_name=last_name, |
|
||||||
# email=email, |
|
||||||
# phone=phone, |
|
||||||
# address=address, |
|
||||||
# zip_code=zip_code, |
|
||||||
# city=city, |
|
||||||
# # region=region, |
|
||||||
# created_by=request.user, |
|
||||||
# modified_by=request.user |
|
||||||
# ) |
|
||||||
# messages.success(request, f'Prospect {name} has been added successfully!') |
|
||||||
# return redirect('crm:events') # or wherever you want to redirect after success |
|
||||||
# except Exception as e: |
|
||||||
# messages.error(request, f'Error adding prospect: {str(e)}') |
|
||||||
|
|
||||||
# return render(request, 'crm/add_prospect.html') |
|
||||||
|
|
||||||
class EventCreateView(CRMAccessMixin, CreateView): |
|
||||||
model = Event |
|
||||||
form_class = EventForm |
|
||||||
template_name = 'crm/event_form.html' |
|
||||||
success_url = reverse_lazy('crm:planned_events') |
|
||||||
|
|
||||||
def get_initial(self): |
|
||||||
initial = super().get_initial() |
|
||||||
prospect_id = self.kwargs.get('prospect_id') |
|
||||||
if prospect_id: |
|
||||||
initial['prospects'] = [prospect_id] |
|
||||||
return initial |
|
||||||
|
|
||||||
def form_valid(self, form): |
|
||||||
form.instance.created_by = self.request.user |
|
||||||
form.instance.modified_by = self.request.user |
|
||||||
return super().form_valid(form) |
|
||||||
|
|
||||||
class EditEventView(CRMAccessMixin, UpdateView): |
|
||||||
model = Event |
|
||||||
form_class = EventForm |
|
||||||
template_name = 'crm/event_form.html' |
|
||||||
success_url = reverse_lazy('crm:planned_events') |
|
||||||
|
|
||||||
def form_valid(self, form): |
|
||||||
form.instance.modified_by = self.request.user |
|
||||||
response = super().form_valid(form) |
|
||||||
messages.success(self.request, 'Event updated successfully!') |
|
||||||
return response |
|
||||||
|
|
||||||
class StartEventView(CRMAccessMixin, BaseUpdateView): |
|
||||||
model = Event |
|
||||||
http_method_names = ['post', 'get'] |
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs): |
|
||||||
return self.post(request, *args, **kwargs) |
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs): |
|
||||||
event = get_object_or_404(Event, pk=kwargs['pk'], status='PLANNED') |
|
||||||
event.status = 'ACTIVE' |
|
||||||
event.save() |
|
||||||
|
|
||||||
if event.type == 'MAIL': |
|
||||||
return HttpResponseRedirect( |
|
||||||
reverse_lazy('crm:setup_email_campaign', kwargs={'event_id': event.id}) |
|
||||||
) |
|
||||||
elif event.type == 'SMS': |
|
||||||
return HttpResponseRedirect( |
|
||||||
reverse_lazy('crm:setup_sms_campaign', kwargs={'event_id': event.id}) |
|
||||||
) |
|
||||||
elif event.type == 'PRESS': |
|
||||||
return HttpResponseRedirect( |
|
||||||
reverse_lazy('crm:setup_press_release', kwargs={'event_id': event.id}) |
|
||||||
) |
|
||||||
|
|
||||||
messages.success(request, 'Event started successfully!') |
|
||||||
return HttpResponseRedirect(reverse_lazy('crm:planned_events')) |
|
||||||
|
|
||||||
class EventListView(CRMAccessMixin, ListView): |
|
||||||
model = Event |
|
||||||
template_name = 'crm/events.html' |
|
||||||
context_object_name = 'events' # We won't use this since we're providing custom context |
|
||||||
|
|
||||||
def get_context_data(self, **kwargs): |
|
||||||
context = super().get_context_data(**kwargs) |
|
||||||
context['planned_events'] = Event.objects.filter( |
|
||||||
status='PLANNED' |
|
||||||
).order_by('date') |
|
||||||
context['completed_events'] = Event.objects.filter( |
|
||||||
status='COMPLETED' |
|
||||||
).order_by('-date') |
|
||||||
return context |
|
||||||
|
|
||||||
class ProspectListView(CRMAccessMixin, ListView): |
|
||||||
model = Prospect |
|
||||||
template_name = 'crm/prospect_list.html' |
|
||||||
context_object_name = 'prospects' |
|
||||||
filterset_class = ProspectFilter |
|
||||||
|
|
||||||
def get_queryset(self): |
|
||||||
return super().get_queryset().prefetch_related('prospectstatus_set__status') |
|
||||||
|
|
||||||
def get_context_data(self, **kwargs): |
|
||||||
context = super().get_context_data(**kwargs) |
|
||||||
context['filter'] = self.filterset_class( |
|
||||||
self.request.GET, |
|
||||||
queryset=self.get_queryset() |
|
||||||
) |
|
||||||
return context |
|
||||||
|
|
||||||
class CSVImportView(CRMAccessMixin, FormView): |
|
||||||
template_name = 'crm/csv_import.html' |
|
||||||
form_class = CSVImportForm |
|
||||||
success_url = reverse_lazy('prospect-list') |
|
||||||
|
|
||||||
def form_valid(self, form): |
|
||||||
csv_file = TextIOWrapper( |
|
||||||
form.cleaned_data['csv_file'].file, |
|
||||||
encoding='utf-8-sig' # Handle potential BOM in CSV |
|
||||||
) |
|
||||||
reader = csv.reader(csv_file, delimiter=';') # Using semicolon delimiter |
|
||||||
|
|
||||||
# Skip header if exists |
|
||||||
next(reader, None) |
|
||||||
|
|
||||||
created_count = 0 |
|
||||||
updated_count = 0 |
|
||||||
error_count = 0 |
|
||||||
|
|
||||||
for row in reader: |
|
||||||
try: |
|
||||||
if len(row) < 10: # Ensure we have enough columns |
|
||||||
continue |
|
||||||
|
|
||||||
# Extract data from correct columns |
|
||||||
entity_name = row[0].strip() |
|
||||||
last_name = row[1].strip() |
|
||||||
first_name = row[2].strip() |
|
||||||
email = row[3].strip() |
|
||||||
phone = row[4].strip() |
|
||||||
zip_code = row[8].strip() |
|
||||||
city = row[9].strip() |
|
||||||
|
|
||||||
# Try to update existing prospect or create new one |
|
||||||
prospect, created = Prospect.objects.update_or_create( |
|
||||||
email=email, # Use email as unique identifier |
|
||||||
defaults={ |
|
||||||
'entity_name': entity_name, |
|
||||||
'first_name': first_name, |
|
||||||
'last_name': last_name, |
|
||||||
'phone': phone, |
|
||||||
'zip_code': zip_code, |
|
||||||
'city': city, |
|
||||||
'modified_by': self.request.user, |
|
||||||
} |
|
||||||
) |
|
||||||
|
|
||||||
if created: |
|
||||||
prospect.created_by = self.request.user |
|
||||||
prospect.save() |
|
||||||
created_count += 1 |
|
||||||
else: |
|
||||||
updated_count += 1 |
|
||||||
|
|
||||||
except Exception as e: |
|
||||||
error_count += 1 |
|
||||||
messages.error( |
|
||||||
self.request, |
|
||||||
f"Error processing row with email {email}: {str(e)}" |
|
||||||
) |
|
||||||
|
|
||||||
# Add success message |
|
||||||
messages.success( |
|
||||||
self.request, |
|
||||||
f"Import completed: {created_count} created, {updated_count} updated, {error_count} errors" |
|
||||||
) |
|
||||||
|
|
||||||
return super().form_valid(form) |
|
||||||
|
|
||||||
class SendBulkEmailView(CRMAccessMixin, FormView): |
|
||||||
template_name = 'crm/send_bulk_email.html' |
|
||||||
form_class = BulkEmailForm |
|
||||||
success_url = reverse_lazy('crm:prospect-list') |
|
||||||
|
|
||||||
def form_valid(self, form): |
|
||||||
prospects = form.cleaned_data['prospects'] |
|
||||||
subject = form.cleaned_data['subject'] |
|
||||||
content = form.cleaned_data['content'] |
|
||||||
|
|
||||||
# Create Event for this email campaign |
|
||||||
event = Event.objects.create( |
|
||||||
date=datetime.now(), |
|
||||||
type=EventType.MAILING, |
|
||||||
description=f"Bulk email: {subject}", |
|
||||||
status='COMPLETED', |
|
||||||
created_by=self.request.user, |
|
||||||
modified_by=self.request.user |
|
||||||
) |
|
||||||
event.prospects.set(prospects) |
|
||||||
|
|
||||||
# Send emails |
|
||||||
success_count, error_count = send_bulk_email( |
|
||||||
subject=subject, |
|
||||||
content=content, |
|
||||||
prospects=prospects |
|
||||||
) |
|
||||||
|
|
||||||
# Show result message |
|
||||||
messages.success( |
|
||||||
self.request, |
|
||||||
f"Sent {success_count} emails successfully. {error_count} failed." |
|
||||||
) |
|
||||||
|
|
||||||
return super().form_valid(form) |
|
||||||
|
unable to load file from base commit
|
@ -0,0 +1,18 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-07-03 10:01 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('tournaments', '0130_playerregistration_contact_email_and_more'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AlterField( |
||||||
|
model_name='playerregistration', |
||||||
|
name='contact_name', |
||||||
|
field=models.CharField(blank=True, max_length=200, null=True), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-07-09 12:55 |
||||||
|
|
||||||
|
import django.db.models.deletion |
||||||
|
from django.conf import settings |
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('tournaments', '0131_alter_playerregistration_contact_name'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AlterField( |
||||||
|
model_name='purchase', |
||||||
|
name='user', |
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchases', to=settings.AUTH_USER_MODEL), |
||||||
|
), |
||||||
|
] |
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in new issue