diff --git a/.gitignore b/.gitignore index 9daa608..321daa2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ padelclub_backend/settings_local.py myenv/ shared/config_local.py +logs/ # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/biz/admin.py b/biz/admin.py index e1a4556..8fd2989 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, ProspectGroup from .forms import FileImportForm, EmailTemplateSelectionForm -from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter +from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter, ProspectGroupFilter 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, ProspectGroupFilter, '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(ProspectGroup) +class ProspectGroupAdmin(SyncedObjectAdmin): + list_display = ('name', 'user_count') + date_hierarchy = 'creation_date' @admin.register(Activity) class ActivityAdmin(SyncedObjectAdmin): @@ -429,57 +437,3 @@ class ActivityAdmin(SyncedObjectAdmin): 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( -# ' {}', -# date.strftime('%Y-%m-%d %H:%M') if date else '' -# ) -# return format_html('') diff --git a/biz/filters.py b/biz/filters.py index 9c3f6a2..6a03c5a 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, ProspectGroup User = get_user_model() @@ -110,6 +110,19 @@ class ProspectDeclineReasonFilter(admin.SimpleListFilter): else: return queryset +class ProspectGroupFilter(admin.SimpleListFilter): + title = 'ProspectGroup' + parameter_name = 'prospect_group' + + def lookups(self, request, model_admin): + prospect_groups = ProspectGroup.objects.all().order_by('-creation_date') + return [(group.id, group.name) for group in prospect_groups] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(prospect_groups__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/migrations/0006_alter_campaign_id.py b/biz/migrations/0006_alter_campaign_id.py new file mode 100644 index 0000000..46bdb24 --- /dev/null +++ b/biz/migrations/0006_alter_campaign_id.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1 on 2025-09-22 13:10 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('biz', '0005_alter_activity_status_campaign'), + ] + + operations = [ + # migrations.AlterField( + # model_name='campaign', + # name='id', + # field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False), + # ), + ] diff --git a/biz/migrations/0007_prospectgroup_delete_campaign.py b/biz/migrations/0007_prospectgroup_delete_campaign.py new file mode 100644 index 0000000..4603934 --- /dev/null +++ b/biz/migrations/0007_prospectgroup_delete_campaign.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1 on 2025-09-22 14:08 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('biz', '0006_alter_campaign_id'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ProspectGroup', + fields=[ + ('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)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('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='prospect_groups', 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, + }, + ), + migrations.DeleteModel( + name='Campaign', + ), + ] diff --git a/biz/models.py b/biz/models.py index e406541..cc3eb0f 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,20 @@ class EmailTemplate(BaseModel): def delete_dependencies(self): pass +class ProspectGroup(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + name = models.CharField(max_length=200, null=True, blank=True) + prospects = models.ManyToManyField(Prospect, blank=True, related_name='prospect_groups') + + def user_count(self): + return self.prospects.count() + + def __str__(self): + return self.name + + def delete_dependencies(self): + pass + # class EmailCampaign(models.Model): # event = models.OneToOneField(Event, on_delete=models.CASCADE) # subject = models.CharField(max_length=200) diff --git a/sync/README.md b/sync/README.md new file mode 100644 index 0000000..b309631 --- /dev/null +++ b/sync/README.md @@ -0,0 +1,21 @@ +### Synchronization quick ReadMe + +- Data class must extend BaseModel +- Admin classes must extend SyncedObjectAdmin to have updates saved in the BaseModel properties +- The SynchronizationApi defines a get and a post service to POST new data, and GET updates. When performing an operation on a data, a ModelLog instance is created with the related information. When performing a GET, we retrieve the list of ModelLogs to sent the new data to the user. +- routing.py defines the URL of the websocket where messages are sent when updates are made. URL is by user. + + +### Sharing + +- Data can be shared between users. To do that, a new DataAccess object can be created to define the owner, the authorized user, and the object id. +- By default, the whole hierarchy of objects are shared, from the data parents to all its children. +- Special data path can be specified for a class by defining a setting + +example: +SYNC_MODEL_CHILDREN_SHARING = { + 'Match': ['team_scores', 'team_registration', 'player_registrations'] +} +Here when sharing a Match, we also share objects accessed through the names of the properties to get TeamScore, TeamRegistration and PlayerRegistration. + +- It's also possible to exclude a class from being sharable by setting sharable = False in its definition. In PadelClub, Club is the top entity that links all data together, so we don't want the automatic data scanning to share clubs. diff --git a/sync/admin.py b/sync/admin.py index 0f9f713..296df11 100644 --- a/sync/admin.py +++ b/sync/admin.py @@ -5,7 +5,7 @@ from .models import BaseModel, ModelLog, DataAccess class SyncedObjectAdmin(admin.ModelAdmin): - exclude = ('data_access_ids',) + # exclude = ('data_access_ids',) raw_id_fields = ['related_user', 'last_updated_by'] def save_model(self, request, obj, form, change): diff --git a/sync/models/base.py b/sync/models/base.py index 562dd56..564def2 100644 --- a/sync/models/base.py +++ b/sync/models/base.py @@ -16,6 +16,8 @@ class BaseModel(models.Model): last_updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL, related_name='+') data_access_ids = models.JSONField(default=list) + sharable = True + class Meta: abstract = True @@ -39,6 +41,8 @@ class BaseModel(models.Model): } def update_data_access_list(self): + if self.sharable == False: + return related_instances = self.sharing_related_instances() data_access_ids = set() for instance in related_instances: @@ -46,21 +50,18 @@ class BaseModel(models.Model): data_access_ids.update(instance.data_access_ids) # print(f'related_instances = {related_instances}') - # data_access_ids = [instance.data_access_ids for instance in related_instances if isinstance(instance, BaseModel)] - # data_access_ids.extend(self.data_access_ids) self.data_access_ids = list(data_access_ids) - # DataAccess = apps.get_model('sync', 'DataAccess') - # data_accesses = DataAccess.objects.filter(model_id__in=related_ids) - # for data_access in data_accesses: - # self.add_data_access_relation(data_access) - def add_data_access_relation(self, data_access): + if self.sharable == False: + return str_id = str(data_access.id) if str_id not in self.data_access_ids: self.data_access_ids.append(str_id) def remove_data_access_relation(self, data_access): + if self.sharable == False: + return try: self.data_access_ids.remove(str(data_access.id)) except ValueError: diff --git a/tournaments/admin.py b/tournaments/admin.py index 4febf0d..de9596a 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, ProspectGroup 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_group'] 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_group(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) + prospect_group = ProspectGroup.objects.create( + name=f"{datetime.now().strftime('%Y-%m-%d_%H:%M')}", + ) + prospect_group.prospects.add(*prospects) + messages.success(request, f'Created campaign {prospect_group.name} with {queryset.count()} prospects') + + create_group.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(): @@ -95,7 +120,7 @@ class EventAdmin(SyncedObjectAdmin): readonly_fields = ['display_images_preview'] fieldsets = [ - (None, {'fields': ['last_update', 'related_user', 'name', 'club', 'creator', 'creation_date', 'tenup_id']}), + (None, {'fields': ['data_access_ids','last_update', 'related_user', 'name', 'club', 'creator', 'creation_date', 'tenup_id']}), ('Images', {'fields': ['display_images_preview'], 'classes': ['collapse']}), ] diff --git a/tournaments/models/club.py b/tournaments/models/club.py index d0f6b56..3582f51 100644 --- a/tournaments/models/club.py +++ b/tournaments/models/club.py @@ -27,6 +27,8 @@ class Club(BaseModel): broadcast_code = models.CharField(max_length=10, null=True, blank=True, unique=True) admin_visible = models.BooleanField(default=False) + sharable = False + def delete_dependencies(self): for court in self.courts.all(): # court.delete_dependencies()