diff --git a/biz/admin.py b/biz/admin.py index a83003e..c853ffb 100644 --- a/biz/admin.py +++ b/biz/admin.py @@ -12,9 +12,9 @@ import io import time import logging -from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate, DeclinationReason +from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate, DeclinationReason, Campaign from .forms import FileImportForm, EmailTemplateSelectionForm -from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter +from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter, ProspectCampaignFilter from tournaments.models import CustomUser from tournaments.models.enums import UserOrigin @@ -75,6 +75,10 @@ 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 create_default_activity_for_prospect(modeladmin, request, queryset, type, status, reason): for prospect in queryset: activity = Activity.objects.create( @@ -114,13 +118,13 @@ class ProspectAdmin(SyncedObjectAdmin): ] list_display = ('first_name', 'last_name', 'entity_names', 'last_update_date', 'current_status', 'current_text', 'contact_again') - list_filter = (ContactAgainFilter, ProspectStatusFilter, ProspectDeclineReasonFilter, 'creation_date', StaffUserFilter, 'source', ProspectProfileFilter) + list_filter = (ContactAgainFilter, ProspectStatusFilter, ProspectDeclineReasonFilter, ProspectCampaignFilter, 'creation_date', StaffUserFilter, 'source', ProspectProfileFilter) search_fields = ('first_name', 'last_name', 'email', 'entities__name') 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, declined_too_expensive, declined_use_something_else, declined_android_user] + 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] raw_id_fields = ['official_user', 'related_user'] def last_update_date(self, obj): @@ -397,6 +401,10 @@ class ProspectAdmin(SyncedObjectAdmin): time.sleep(1) +@admin.register(Campaign) +class CampaignAdmin(SyncedObjectAdmin): + list_display = ('name', 'user_count') + date_hierarchy = 'creation_date' @admin.register(Activity) class ActivityAdmin(SyncedObjectAdmin): diff --git a/biz/filters.py b/biz/filters.py index 9c3f6a2..6e10e8c 100644 --- a/biz/filters.py +++ b/biz/filters.py @@ -7,7 +7,7 @@ from django.utils import timezone from dateutil.relativedelta import relativedelta -from .models import Activity, Prospect, Status, DeclinationReason +from .models import Activity, Prospect, Status, DeclinationReason, Campaign User = get_user_model() @@ -110,6 +110,19 @@ class ProspectDeclineReasonFilter(admin.SimpleListFilter): else: return queryset +class ProspectCampaignFilter(admin.SimpleListFilter): + title = 'Campaign' + parameter_name = 'campaign' + + def lookups(self, request, model_admin): + campaigns = Campaign.objects.all().order_by('-creation_date') + return [(campaign.id, campaign.name) for campaign in campaigns] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(campaigns__id=self.value()) + return queryset + class ContactAgainFilter(admin.SimpleListFilter): title = 'Contact again' # or whatever you want parameter_name = 'contact_again' diff --git a/biz/migrations/0005_alter_activity_status_campaign.py b/biz/migrations/0005_alter_activity_status_campaign.py new file mode 100644 index 0000000..7dfa999 --- /dev/null +++ b/biz/migrations/0005_alter_activity_status_campaign.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1 on 2025-09-22 12:34 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('biz', '0004_prospect_contact_again'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='status', + field=models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('NOT_CONCERNED', 'Not concerned'), ('SHOULD_BUY', 'Should buy'), ('HAVE_CREATED_ACCOUNT', 'Have created account')], max_length=50, null=True), + ), + migrations.CreateModel( + name='Campaign', + 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)), + ('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('prospects', models.ManyToManyField(blank=True, related_name='campaigns', to='biz.prospect')), + ('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, + }, + ), + ] diff --git a/biz/models.py b/biz/models.py index e406541..69401a6 100644 --- a/biz/models.py +++ b/biz/models.py @@ -25,6 +25,7 @@ class Status(models.TextChoices): # DECLINED_UNRELATED = 'DECLINED_UNRELATED', 'Declined without significance' NOT_CONCERNED = 'NOT_CONCERNED', 'Not concerned' SHOULD_BUY = 'SHOULD_BUY', 'Should buy' + HAVE_CREATED_ACCOUNT = 'HAVE_CREATED_ACCOUNT', 'Have created account' class DeclinationReason(models.TextChoices): TOO_EXPENSIVE = 'TOO_EXPENSIVE', 'Too expensive' @@ -170,6 +171,13 @@ class EmailTemplate(BaseModel): def delete_dependencies(self): pass +class Campaign(BaseModel): + name = models.CharField(max_length=200, null=True, blank=True) + prospects = models.ManyToManyField(Prospect, blank=True, related_name='campaigns') + + def user_count(self): + return self.prospects.count() + # class EmailCampaign(models.Model): # event = models.OneToOneField(Event, on_delete=models.CASCADE) # subject = models.CharField(max_length=200) diff --git a/tournaments/admin.py b/tournaments/admin.py index b8fd869..49d013f 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -9,7 +9,7 @@ from django.shortcuts import render from django.db.models import Avg from datetime import timedelta, datetime -from biz.models import Prospect +from biz.models import Prospect, Campaign from .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image from .forms import CustomUserCreationForm, CustomUserChangeForm @@ -27,7 +27,7 @@ class CustomUserAdmin(UserAdmin): model = CustomUser search_fields = ['username', 'email', 'phone', 'first_name', 'last_name', 'licence_id'] filter_horizontal = ('clubs',) - actions = ['convert_to_prospect'] + actions = ['convert_to_prospect', 'create_campaign'] list_display = ['email', 'first_name', 'last_name', 'username', 'date_joined', 'latest_event_club_name', 'is_active', 'event_count', 'origin', 'registration_payment_mode', 'licence_id'] list_filter = ['is_active', 'origin', UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter] @@ -59,10 +59,35 @@ class CustomUserAdmin(UserAdmin): obj.last_update = timezone.now() super().save_model(request, obj, form, change) + def create_campaign(self, request, queryset): + prospects = [] + source_value = f"auto_created_{datetime.now().strftime('%Y-%m-%d_%H:%M')}" + for user in queryset: + prospect = Prospect.objects.filter(email=user.email).first() + if prospect: + prospects.append(prospect) + else: + prospect = Prospect.objects.create( + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + phone=user.phone, + official_user=user, + source=source_value + ) + prospects.append(prospect) + campaign = Campaign.objects.create( + name=f"{datetime.now().strftime('%Y-%m-%d_%H:%M')}", + ) + campaign.prospects.add(*prospects) + messages.success(request, f'Created campaign {campaign.name} with {queryset.count()} prospects') + + create_campaign.short_description = "Create campaign with selection" + def convert_to_prospect(self, request, queryset): created_count = 0 skipped_count = 0 - source_value = f"user_conversion_{datetime.now().strftime('%Y%m%d_%H%M')}" + source_value = f"user_conversion_{datetime.now().strftime('%Y-%m-%d_%H:%M')}" for user in queryset: if user.email and Prospect.objects.filter(email=user.email).exists():