You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
534 lines
23 KiB
534 lines
23 KiB
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
|
|
from django.db.models import Q, Max, Subquery, OuterRef
|
|
|
|
import csv
|
|
import io
|
|
import time
|
|
import logging
|
|
|
|
from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate, DeclinationReason, ProspectGroup
|
|
from .forms import FileImportForm, EmailTemplateSelectionForm
|
|
from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter
|
|
|
|
from tournaments.models import CustomUser
|
|
from tournaments.models.enums import UserOrigin
|
|
from sync.admin import SyncedObjectAdmin
|
|
|
|
User = get_user_model()
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class ProspectInline(admin.StackedInline):
|
|
model = Prospect.entities.through
|
|
extra = 1
|
|
verbose_name = "Prospect"
|
|
verbose_name_plural = "Prospects"
|
|
autocomplete_fields = ['prospect']
|
|
|
|
@admin.register(Entity)
|
|
class EntityAdmin(SyncedObjectAdmin):
|
|
list_display = ('name', 'address', 'zip_code', 'city')
|
|
search_fields = ('name', 'address', 'zip_code', 'city')
|
|
# filter_horizontal = ('prospects',)
|
|
inlines = [ProspectInline]
|
|
|
|
@admin.register(EmailTemplate)
|
|
class EmailTemplateAdmin(SyncedObjectAdmin):
|
|
list_display = ('name', 'subject', 'body')
|
|
search_fields = ('name', 'subject')
|
|
exclude = ('data_access_ids', 'activities',)
|
|
|
|
def contacted_by_sms(modeladmin, request, queryset):
|
|
create_default_activity_for_prospect(modeladmin, request, queryset, ActivityType.SMS, Status.CONTACTED, None)
|
|
contacted_by_sms.short_description = "Contacted by SMS"
|
|
|
|
def mark_as_inbound(modeladmin, request, queryset):
|
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.INBOUND, None)
|
|
mark_as_inbound.short_description = "Mark as inbound"
|
|
|
|
def mark_as_customer(modeladmin, request, queryset):
|
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.CUSTOMER, None)
|
|
mark_as_customer.short_description = "Mark as customer"
|
|
|
|
def mark_as_should_test(modeladmin, request, queryset):
|
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.SHOULD_TEST, None)
|
|
mark_as_should_test.short_description = "Mark as should test"
|
|
|
|
def mark_as_testing(modeladmin, request, queryset):
|
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.TESTING, None)
|
|
mark_as_testing.short_description = "Mark as testing"
|
|
|
|
def declined_too_expensive(modeladmin, request, queryset):
|
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.TOO_EXPENSIVE)
|
|
declined_too_expensive.short_description = "Declined too expensive"
|
|
|
|
def declined_use_something_else(modeladmin, request, queryset):
|
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_OTHER_PRODUCT)
|
|
declined_use_something_else.short_description = "Declined use something else"
|
|
|
|
def declined_android_user(modeladmin, request, queryset):
|
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_ANDROID)
|
|
declined_android_user.short_description = "Declined use Android"
|
|
|
|
def mark_as_have_account(modeladmin, request, queryset):
|
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.HAVE_CREATED_ACCOUNT, None)
|
|
mark_as_have_account.short_description = "Mark as having an account"
|
|
|
|
def mark_as_not_concerned(modeladmin, request, queryset):
|
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.NOT_CONCERNED, None)
|
|
mark_as_not_concerned.short_description = "Mark as not concerned"
|
|
|
|
def create_default_activity_for_prospect(modeladmin, request, queryset, type, status, reason):
|
|
for prospect in queryset:
|
|
activity = Activity.objects.create(
|
|
type=type,
|
|
status=status,
|
|
declination_reason=reason,
|
|
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:biz_activity_add')
|
|
url += f'?prospect={prospect.id}'
|
|
return redirect(url)
|
|
create_activity_for_prospect.short_description = "Create activity"
|
|
|
|
@admin.register(Prospect)
|
|
class ProspectAdmin(SyncedObjectAdmin):
|
|
readonly_fields = ['related_activities', 'entity_names', 'current_status', 'id']
|
|
fieldsets = [
|
|
(None, {
|
|
'fields': ['related_activities', 'id', 'entity_names', 'first_name', 'last_name', 'email', 'phone', 'contact_again', 'official_user', 'name_unsure', 'entities', 'related_user']
|
|
}),
|
|
]
|
|
list_display = ('first_name', 'last_name', 'entity_names', 'phone', 'last_update_date', 'current_status', 'contact_again')
|
|
|
|
list_filter = (ContactAgainFilter, ProspectStatusFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter, 'creation_date', StaffUserFilter, 'source', ProspectProfileFilter)
|
|
search_fields = ('first_name', 'last_name', 'email', 'phone')
|
|
date_hierarchy = 'creation_date'
|
|
change_list_template = "admin/biz/prospect/change_list.html"
|
|
ordering = ['-last_update']
|
|
filter_horizontal = ['entities']
|
|
actions = ['send_email', create_activity_for_prospect, mark_as_inbound, contacted_by_sms, mark_as_should_test, mark_as_testing, mark_as_customer, mark_as_have_account, declined_too_expensive, declined_use_something_else, declined_android_user, mark_as_not_concerned]
|
|
autocomplete_fields = ['official_user', 'related_user']
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if obj.related_user is None:
|
|
obj.related_user = request.user
|
|
super().save_model(request, obj, form, change)
|
|
|
|
def last_update_date(self, obj):
|
|
return obj.last_update.date() if obj.last_update else None
|
|
last_update_date.short_description = 'Last Update'
|
|
last_update_date.admin_order_field = 'last_update'
|
|
|
|
def related_activities(self, obj):
|
|
activities = obj.activities.all()
|
|
if activities:
|
|
activity_links = []
|
|
for activity in activities:
|
|
url = f"/kingdom/biz/activity/{activity.id}/change/"
|
|
activity_links.append(f'<a href="{url}">{activity.html_desc()}</a>')
|
|
return format_html('<br>'.join(activity_links))
|
|
return "No events"
|
|
related_activities.short_description = "Related Activities"
|
|
|
|
def get_urls(self):
|
|
urls = super().get_urls()
|
|
custom_urls = [
|
|
path('dashboard/', self.admin_site.admin_view(self.dashboard), name='biz_dashboard'),
|
|
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 dashboard(self, request):
|
|
"""
|
|
Dashboard view showing prospects organized by status columns
|
|
"""
|
|
# Get filter parameter - if 'my' is true, filter by current user
|
|
filter_my = request.GET.get('my', 'false') == 'true'
|
|
|
|
# Base queryset
|
|
base_queryset = Prospect.objects.select_related().prefetch_related('entities', 'activities')
|
|
|
|
# Apply user filter if requested
|
|
if filter_my:
|
|
base_queryset = base_queryset.filter(related_user=request.user)
|
|
|
|
# Helper function to get prospects by status
|
|
def get_prospects_by_status(statuses):
|
|
# Get the latest activity status for each prospect
|
|
latest_activity = Activity.objects.filter(
|
|
prospects=OuterRef('pk'),
|
|
status__isnull=False
|
|
).order_by('-creation_date')
|
|
|
|
prospects = base_queryset.annotate(
|
|
latest_status=Subquery(latest_activity.values('status')[:1])
|
|
).filter(
|
|
latest_status__in=statuses
|
|
).order_by('last_update')
|
|
|
|
return prospects
|
|
|
|
# Get prospects for each column
|
|
should_test_prospects = get_prospects_by_status([Status.SHOULD_TEST])
|
|
testing_prospects = get_prospects_by_status([Status.TESTING])
|
|
responded_prospects = get_prospects_by_status([Status.RESPONDED])
|
|
others_prospects = get_prospects_by_status([Status.INBOUND, Status.SHOULD_BUY])
|
|
|
|
# Get prospects with contact_again date set, sorted by oldest first
|
|
contact_again_prospects = base_queryset.filter(
|
|
contact_again__isnull=False
|
|
).order_by('contact_again')
|
|
|
|
context = {
|
|
'title': 'CRM Dashboard',
|
|
'should_test_prospects': should_test_prospects,
|
|
'testing_prospects': testing_prospects,
|
|
'responded_prospects': responded_prospects,
|
|
'others_prospects': others_prospects,
|
|
'contact_again_prospects': contact_again_prospects,
|
|
'filter_my': filter_my,
|
|
'opts': self.model._meta,
|
|
'has_view_permission': self.has_view_permission(request),
|
|
}
|
|
|
|
return render(request, 'admin/biz/dashboard.html', context)
|
|
|
|
def cleanup(self, request):
|
|
Entity.objects.all().delete()
|
|
Prospect.objects.all().delete()
|
|
Activity.objects.all().delete()
|
|
|
|
messages.success(request, 'cleanup biz objects')
|
|
return redirect('admin:biz_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:biz_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:biz_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/biz/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), delimiter=';')
|
|
created_prospects = 0
|
|
updated_prospects = 0
|
|
created_entities = 0
|
|
created_events = 0
|
|
|
|
for row in csv_reader:
|
|
print(f'>>> row size is {len(row)}')
|
|
|
|
if len(row) < 5:
|
|
print(f'>>> WARNING: row size is {len(row)}: {row}')
|
|
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,
|
|
'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 = None
|
|
# declination_reason = 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 'TESTING' in status_text:
|
|
# status_value = Status.TESTING
|
|
# elif 'LOST' in status_text:
|
|
# status_value = Status.LOST
|
|
# elif 'DECLINED_TOO_EXPENSIVE' in status_text:
|
|
# status_value = Status.DECLINED
|
|
# declination_reason = DeclinationReason.TOO_EXPENSIVE
|
|
# elif 'USE_OTHER_PRODUCT' in status_text:
|
|
# status_value = Status.DECLINED
|
|
# declination_reason = DeclinationReason.USE_OTHER_PRODUCT
|
|
# elif 'USE_ANDROID' in status_text:
|
|
# status_value = Status.DECLINED
|
|
# declination_reason = DeclinationReason.USE_ANDROID
|
|
# elif 'NOK' in status_text:
|
|
# status_value = Status.DECLINED
|
|
# declination_reason = DeclinationReason.UNKNOWN
|
|
# elif 'DECLINED_UNRELATED' in status_text:
|
|
# status_value = Status.DECLINED_UNRELATED
|
|
|
|
# activity = Activity.objects.create(
|
|
# type=ActivityType.SMS,
|
|
# attachment_text=attachment_text,
|
|
# status=status_value,
|
|
# declination_reason=declination_reason,
|
|
# 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']
|
|
|
|
sent_count, failed_count = self.process_selected_items_with_template(request, queryset, email_template)
|
|
|
|
if failed_count > 0:
|
|
self.message_user(request, f"Email sent to {sent_count} prospects, {failed_count} failed using the '{email_template.name}' template.", messages.WARNING)
|
|
else:
|
|
self.message_user(request, f"Email sent to {sent_count} prospects using the '{email_template.name}' template.", messages.SUCCESS)
|
|
return HttpResponseRedirect(request.get_full_path())
|
|
else:
|
|
form = EmailTemplateSelectionForm()
|
|
|
|
return render(request, 'admin/biz/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}}',
|
|
f' {prospect.first_name}' if prospect.first_name and len(prospect.first_name) > 0 else ''
|
|
)
|
|
# 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
|
|
|
|
activity = Activity.objects.create(
|
|
type=ActivityType.MAIL,
|
|
status=Status.CONTACTED,
|
|
description=f"Email sent: {email_template.subject}"
|
|
)
|
|
activity.prospects.add(prospect)
|
|
except Exception as e:
|
|
error_emails.append(prospect.email)
|
|
logger.error(f'Failed to send email to {prospect.email}: {str(e)}')
|
|
|
|
time.sleep(1)
|
|
|
|
if error_emails:
|
|
logger.error(f'Failed to send emails to: {error_emails}')
|
|
|
|
return sent_count, len(error_emails)
|
|
|
|
@admin.register(ProspectGroup)
|
|
class ProspectGroupAdmin(SyncedObjectAdmin):
|
|
list_display = ('name', 'user_count')
|
|
date_hierarchy = 'creation_date'
|
|
raw_id_fields = ['related_user']
|
|
|
|
@admin.register(Activity)
|
|
class ActivityAdmin(SyncedObjectAdmin):
|
|
# raw_id_fields = ['prospects']
|
|
list_display = ('prospect_names', 'last_update', 'status', 'type', 'description', 'attachment_text', )
|
|
list_filter = ('status', 'type')
|
|
search_fields = ('attachment_text',)
|
|
date_hierarchy = 'last_update'
|
|
autocomplete_fields = ['prospects', 'related_user']
|
|
|
|
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 save_model(self, request, obj, form, change):
|
|
if obj.related_user is None:
|
|
obj.related_user = request.user
|
|
super().save_model(request, obj, form, change)
|
|
|
|
def get_event_display(self, obj):
|
|
return str(obj)
|
|
get_event_display.short_description = 'Activity'
|
|
|