From ff0fb01246677468e0bf7fda3c2548fc4eb5e167 Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 9 Jul 2025 16:27:21 +0200 Subject: [PATCH 1/7] CRM update --- api/serializers.py | 9 +- api/urls.py | 4 + api/views.py | 17 + crm/admin.py | 533 +++++++++++++++--- crm/admin_urls.py | 70 +++ crm/filters.py | 57 +- crm/forms.py | 97 ++-- crm/migrations/0001_initial.py | 114 ++-- ...options_alter_prospect_options_and_more.py | 60 -- ...ospect_region_prospect_address_and_more.py | 32 -- ...pect_name_prospect_entity_name_and_more.py | 32 -- crm/migrations/0005_prospect_phone.py | 18 - crm/models.py | 198 ++++--- crm/serializers.py | 17 + crm/templates/admin/crm/app_index.html | 25 + crm/templates/admin/crm/email_users.html | 81 +++ .../admin/crm/prospect/change_list.html | 22 + .../admin/crm/prospect/import_file.html | 53 ++ .../admin/crm/select_email_template.html | 29 + crm/views.py | 512 ++++++++--------- padelclub_backend/settings.py | 2 +- padelclub_backend/urls.py | 7 + sample_prospects.csv | 11 + ...1_alter_playerregistration_contact_name.py | 18 + .../migrations/0132_alter_purchase_user.py | 20 + tournaments/models/purchase.py | 2 +- 26 files changed, 1365 insertions(+), 675 deletions(-) create mode 100644 crm/admin_urls.py delete mode 100644 crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py delete mode 100644 crm/migrations/0003_remove_prospect_region_prospect_address_and_more.py delete mode 100644 crm/migrations/0004_remove_prospect_name_prospect_entity_name_and_more.py delete mode 100644 crm/migrations/0005_prospect_phone.py create mode 100644 crm/serializers.py create mode 100644 crm/templates/admin/crm/app_index.html create mode 100644 crm/templates/admin/crm/email_users.html create mode 100644 crm/templates/admin/crm/prospect/change_list.html create mode 100644 crm/templates/admin/crm/prospect/import_file.html create mode 100644 crm/templates/admin/crm/select_email_template.html create mode 100644 sample_prospects.csv create mode 100644 tournaments/migrations/0131_alter_playerregistration_contact_name.py create mode 100644 tournaments/migrations/0132_alter_purchase_user.py diff --git a/api/serializers.py b/api/serializers.py index e530a5a..b5bac71 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,10 +1,5 @@ from rest_framework import serializers -from tournaments.models.court import Court -from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer, Image from django.db.utils import IntegrityError -from django.conf import settings - -# email from django.template.loader import render_to_string from django.utils.http import urlsafe_base64_encode from django.utils.encoding import force_bytes @@ -12,9 +7,9 @@ from django.core.mail import EmailMessage from django.contrib.sites.shortcuts import get_current_site from api.tokens import account_activation_token - from shared.cryptography import encryption_util -from tournaments.models.draw_log import DrawLog + +from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer, Image, DrawLog, Court from tournaments.models.enums import UserOrigin, RegistrationPaymentMode class EncryptedUserField(serializers.Field): diff --git a/api/urls.py b/api/urls.py index 13faa2e..24ded13 100644 --- a/api/urls.py +++ b/api/urls.py @@ -29,6 +29,10 @@ router.register(r'device-token', views.DeviceTokenViewSet) router.register(r'data-access', DataAccessViewSet) router.register(r'unregistered-teams', views.UnregisteredTeamViewSet) router.register(r'unregistered-players', views.UnregisteredPlayerViewSet) +### CRM +router.register(r'crm-prospects', views.CRMProspectViewSet) +router.register(r'crm-entities', views.CRMEntityViewSet) +router.register(r'crm-activities', views.CRMActivityViewSet) urlpatterns = [ path('', include(router.urls)), diff --git a/api/views.py b/api/views.py index 820f642..181e6e6 100644 --- a/api/views.py +++ b/api/views.py @@ -1,6 +1,9 @@ from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer, ImageSerializer from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image +from crm.serializers import CRMActivitySerializer, CRMProspectSerializer, CRMEntitySerializer +from crm.models import Activity, Prospect, Entity + from rest_framework import viewsets from rest_framework.response import Response from rest_framework.decorators import api_view @@ -568,3 +571,17 @@ def validate_stripe_account(request): 'error': f'Unexpected error: {str(e)}', 'needs_onboarding': True, }, status=200) + + +### CRM +class CRMActivityViewSet(SoftDeleteViewSet): + queryset = Activity.objects.all() + serializer_class = CRMActivitySerializer + +class CRMProspectViewSet(SoftDeleteViewSet): + queryset = Prospect.objects.all() + serializer_class = CRMProspectSerializer + +class CRMEntityViewSet(SoftDeleteViewSet): + queryset = Entity.objects.all() + serializer_class = CRMEntitySerializer diff --git a/crm/admin.py b/crm/admin.py index 819de9a..5b72094 100644 --- a/crm/admin.py +++ b/crm/admin.py @@ -1,96 +1,455 @@ +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 .models import ( - Prospect, - Status, - ProspectStatus, - Event, - EmailCampaign, - EmailTracker -) + +import csv +import io +import time + +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() + +@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:crm_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(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') +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/crm/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/crm/event/{event.id}/change/" + event_links.append(f'{event.html_desc()}') + return format_html('
'.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:crm_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 CRM objects') + return redirect('admin:crm_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:crm_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:crm_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/crm/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): + + 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/crm/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 = [] + + 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 = 'date' + 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 = '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( - ' {}', - date.strftime('%Y-%m-%d %H:%M') if date else '' - ) - return format_html('') + 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/crm/admin_urls.py b/crm/admin_urls.py new file mode 100644 index 0000000..5d94c6e --- /dev/null +++ b/crm/admin_urls.py @@ -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='crm_email_users'), + path('email_users_count/', email_users_count, name='crm_email_count'), + path('email_users_with_tournaments_count/', email_users_with_tournaments_count, name='crm_email_with_tournaments_count'), + path('email_users_with_tournaments/', email_users_with_tournaments, name='email_users_with_tournaments'), +] diff --git a/crm/filters.py b/crm/filters.py index 97e433b..782481f 100644 --- a/crm/filters.py +++ b/crm/filters.py @@ -1,14 +1,20 @@ 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 .models import Event, Status, Prospect +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') - events = django_filters.ModelMultipleChoiceFilter( - queryset=Event.objects.all(), - field_name='events', + 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') @@ -20,4 +26,45 @@ class ProspectFilter(django_filters.FilterSet): class Meta: model = Prospect - fields = ['name', 'city', 'events', 'zip_code'] + 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 + ) diff --git a/crm/forms.py b/crm/forms.py index 10edf6c..e076e80 100644 --- a/crm/forms.py +++ b/crm/forms.py @@ -1,46 +1,61 @@ 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'}), - } +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'}) + ) diff --git a/crm/migrations/0001_initial.py b/crm/migrations/0001_initial.py index 66531fe..53b1496 100644 --- a/crm/migrations/0001_initial.py +++ b/crm/migrations/0001_initial.py @@ -1,9 +1,9 @@ -# Generated by Django 4.2.11 on 2024-12-08 15:10 +# Generated by Django 5.1 on 2025-07-09 13:33 +import django.db.models.deletion +import django.utils.timezone from django.conf import settings from django.db import migrations, models -import django.db.models.deletion -import uuid class Migration(migrations.Migration): @@ -16,79 +16,87 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Prospect', + name='Activity', 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')), + ('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={ - 'ordering': ['-created_at'], + 'verbose_name_plural': 'Activities', + 'ordering': ['-creation_date'], + 'permissions': [('manage_events', 'Can manage events'), ('view_events', 'Can view events')], }, ), migrations.CreateModel( - name='Event', + name='EmailTemplate', 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')), + ('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='crm.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={ - 'ordering': ['-created_at'], + 'abstract': False, }, ), migrations.CreateModel( - name='EmailCampaign', + name='Entity', 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')), + ('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='EmailTracker', + name='Prospect', 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')), + ('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='crm.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={ - 'unique_together': {('campaign', 'prospect')}, + '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='crm.prospect'), + ), ] diff --git a/crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py b/crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py deleted file mode 100644 index 92b7621..0000000 --- a/crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/crm/migrations/0003_remove_prospect_region_prospect_address_and_more.py b/crm/migrations/0003_remove_prospect_region_prospect_address_and_more.py deleted file mode 100644 index 7ed0884..0000000 --- a/crm/migrations/0003_remove_prospect_region_prospect_address_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/crm/migrations/0004_remove_prospect_name_prospect_entity_name_and_more.py b/crm/migrations/0004_remove_prospect_name_prospect_entity_name_and_more.py deleted file mode 100644 index 47776b9..0000000 --- a/crm/migrations/0004_remove_prospect_name_prospect_entity_name_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/crm/migrations/0005_prospect_phone.py b/crm/migrations/0005_prospect_phone.py deleted file mode 100644 index 1b5a25f..0000000 --- a/crm/migrations/0005_prospect_phone.py +++ /dev/null @@ -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), - ), - ] diff --git a/crm/models.py b/crm/models.py index 5028da6..86b5031 100644 --- a/crm/models.py +++ b/crm/models.py @@ -1,39 +1,64 @@ +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 -import uuid + +from sync.models import BaseModel User = get_user_model() -class EventType(models.TextChoices): - MAILING = 'MAIL', 'Mailing List' +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 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) +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) - 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) + 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 = [ @@ -41,80 +66,89 @@ class Prospect(models.Model): ("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 ' - '.join(filter(None, [self.entity_name, self.first_name, self.last_name, f"({self.email})"])) + return self.full_name() -class Status(models.Model): - name = models.CharField(max_length=100, unique=True) - created_at = models.DateTimeField(auto_now_add=True) +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): - return self.name + if self.status: + return self.status + elif self.type: + return self.type + else: + return f'desc = {self.description}, attachment_text = {self.attachment_text}' -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) + def delete_dependencies(self): + pass 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'] + 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.date.date()}" - -class EmailCampaign(models.Model): - event = models.OneToOneField(Event, on_delete=models.CASCADE) + 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 = '' + for field in fields: + html += f'' + html += '
{field}
' + 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) - 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) + body = models.TextField(null=True, blank=True) + activities = models.ManyToManyField(Activity, blank=True, related_name='email_templates') - class Meta: - unique_together = ['campaign', 'prospect'] + 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'] diff --git a/crm/serializers.py b/crm/serializers.py new file mode 100644 index 0000000..aa9adbc --- /dev/null +++ b/crm/serializers.py @@ -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__' diff --git a/crm/templates/admin/crm/app_index.html b/crm/templates/admin/crm/app_index.html new file mode 100644 index 0000000..0da9f93 --- /dev/null +++ b/crm/templates/admin/crm/app_index.html @@ -0,0 +1,25 @@ + +{% extends "admin/app_index.html" %} +{% load i18n %} + +{% block content %} +
+ + +
+ +{{ block.super }} +{% endblock %} diff --git a/crm/templates/admin/crm/email_users.html b/crm/templates/admin/crm/email_users.html new file mode 100644 index 0000000..437b367 --- /dev/null +++ b/crm/templates/admin/crm/email_users.html @@ -0,0 +1,81 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_list %} + +{% block title %}Email Users{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

Filter Users for Email

+ +
+ {% csrf_token %} + +
+
+ + +
+
+ +
+
+ +
+
+ +
+ +
+
+ + {% if filtered_users %} +
+

Filtered Users ({{ filtered_users|length }} found)

+
+ + + + + + + + + + + {% for user in filtered_users %} + + + + + + + {% empty %} + + + + {% endfor %} + +
EmailOriginHas PurchaseDate Joined
{{ user.email }}{{ user.get_origin_display }}{{ user.has_purchase|yesno:"Yes,No" }}{{ user.date_joined|date:"M d, Y" }}
No users found matching criteria.
+
+
+ {% endif %} +
+{% endblock %} diff --git a/crm/templates/admin/crm/prospect/change_list.html b/crm/templates/admin/crm/prospect/change_list.html new file mode 100644 index 0000000..ecf6373 --- /dev/null +++ b/crm/templates/admin/crm/prospect/change_list.html @@ -0,0 +1,22 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools-items %} + {{ block.super }} +
  • + Import + Import App Users + Reset +
  • +{% endblock %} + +{% block search %} + + {{ block.super }} + +
    + + My Prospects + +
    + +{% endblock %} diff --git a/crm/templates/admin/crm/prospect/import_file.html b/crm/templates/admin/crm/prospect/import_file.html new file mode 100644 index 0000000..5e38b2b --- /dev/null +++ b/crm/templates/admin/crm/prospect/import_file.html @@ -0,0 +1,53 @@ + +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block title %}{% trans 'Import File' %}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    + {% csrf_token %} + +
    +
    + {{ form.source.label_tag }} + {{ form.source }} +
    + +
    + {{ form.file.label_tag }} + {{ form.file }} + {% if form.file.help_text %} +
    {{ form.file.help_text }}
    + {% endif %} + {% if form.file.errors %} +
      + {% for error in form.file.errors %} +
    • {{ error }}
    • + {% endfor %} +
    + {% endif %} +
    +
    + + +
    +
    + +
    +

    {% trans 'Instructions' %}

    +

    {% trans 'Select a file to import and click "Import File" to process it.' %}

    +

    {% trans 'Supported file formats: CSV, Excel (XLSX, XLS), and Text files.' %}

    +
    +{% endblock %} diff --git a/crm/templates/admin/crm/select_email_template.html b/crm/templates/admin/crm/select_email_template.html new file mode 100644 index 0000000..c5f7a89 --- /dev/null +++ b/crm/templates/admin/crm/select_email_template.html @@ -0,0 +1,29 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls %} + +{% block content %} +
    +
    + {% csrf_token %} +

    {{ title }}

    + +

    You have selected the following prospects:

    +
      + {% for prospect in prospects %} +
    • {{ prospect.name }} ({{ prospect.email }})
    • + + {% endfor %} +
    + +
    +

    Select an email template:

    + {{ form.as_p }} +
    + +
    + + +
    +
    +
    +{% endblock %} diff --git a/crm/views.py b/crm/views.py index 5cd72c6..93b74a8 100644 --- a/crm/views.py +++ b/crm/views.py @@ -15,9 +15,9 @@ 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 .models import Event, Prospect, ActivityType from .filters import ProspectFilter -from .forms import ProspectForm, CSVImportForm, BulkEmailForm, EventForm +from .forms import CSVImportForm from .mixins import CRMAccessMixin @@ -25,260 +25,260 @@ 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): +# 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': -# 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 +# 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}) # ) -# 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) +# 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) diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py index 4859194..d142588 100644 --- a/padelclub_backend/settings.py +++ b/padelclub_backend/settings.py @@ -36,7 +36,7 @@ INSTALLED_APPS = [ 'sync', 'tournaments', 'shop', - # 'crm', + 'crm', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', diff --git a/padelclub_backend/urls.py b/padelclub_backend/urls.py index d56034b..7703e47 100644 --- a/padelclub_backend/urls.py +++ b/padelclub_backend/urls.py @@ -17,6 +17,7 @@ from django.contrib import admin from django.urls import include, path from django.conf import settings from django.conf.urls.static import static + from tournaments.admin_utils import download_french_padel_rankings, debug_tools_page, test_player_details_apis, explore_fft_api_endpoints, get_player_license_info, bulk_license_lookup, search_player_by_name, enrich_rankings_with_licenses urlpatterns = [ @@ -33,12 +34,18 @@ urlpatterns = [ path('kingdom/debug/player-license-lookup/', get_player_license_info, name='player_license_lookup'), path('kingdom/debug/bulk-license-lookup/', bulk_license_lookup, name='bulk_license_lookup'), path('kingdom/debug/explore-api-endpoints/', explore_fft_api_endpoints, name='explore_api_endpoints'), + path('kingdom/crm/', include('crm.admin_urls')), path('kingdom/', admin.site.urls), path('api-auth/', include('rest_framework.urls')), path('dj-auth/', include('django.contrib.auth.urls')), ] +def email_users_view(request): + return render(request, 'admin/crm/email_users.html', { + 'title': 'Email Users', + }) + # Serve media files in development if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/sample_prospects.csv b/sample_prospects.csv new file mode 100644 index 0000000..4fbf545 --- /dev/null +++ b/sample_prospects.csv @@ -0,0 +1,11 @@ +first_name,last_name,email,phone +John,Doe,john.doe@example.com,+33123456789 +Jane,Smith,jane.smith@example.com,+33987654321 +Pierre,Martin,pierre.martin@example.com,+33456789123 +Marie,Dubois,marie.dubois@example.com,+33789123456 +Carlos,Rodriguez,carlos.rodriguez@example.com,+34612345678 +Sophie,Leroy,sophie.leroy@example.com,+33234567890 +Michel,Bernard,michel.bernard@example.com,+33345678901 +Laura,Garcia,laura.garcia@example.com,+34723456789 +Thomas,Petit,thomas.petit@example.com,+33456789012 +Emma,Moreau,emma.moreau@example.com,+33567890123 diff --git a/tournaments/migrations/0131_alter_playerregistration_contact_name.py b/tournaments/migrations/0131_alter_playerregistration_contact_name.py new file mode 100644 index 0000000..8ea41b9 --- /dev/null +++ b/tournaments/migrations/0131_alter_playerregistration_contact_name.py @@ -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), + ), + ] diff --git a/tournaments/migrations/0132_alter_purchase_user.py b/tournaments/migrations/0132_alter_purchase_user.py new file mode 100644 index 0000000..ae546bd --- /dev/null +++ b/tournaments/migrations/0132_alter_purchase_user.py @@ -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), + ), + ] diff --git a/tournaments/models/purchase.py b/tournaments/models/purchase.py index 29cba73..e96f602 100644 --- a/tournaments/models/purchase.py +++ b/tournaments/models/purchase.py @@ -4,7 +4,7 @@ from . import BaseModel, CustomUser class Purchase(BaseModel): id = models.BigIntegerField(primary_key=True, unique=True, editable=True) - user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name='purchases') purchase_date = models.DateTimeField() product_id = models.CharField(max_length=100) quantity = models.IntegerField(null=True, blank=True) From e1671892a00ed3449fe7fd63da3af3adef3fe2d6 Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 9 Jul 2025 16:31:01 +0200 Subject: [PATCH 2/7] fix mere --- api/views.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/views.py b/api/views.py index 6727931..c69c63a 100644 --- a/api/views.py +++ b/api/views.py @@ -15,7 +15,6 @@ from django.shortcuts import get_object_or_404 from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer, ImageSerializer from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image -<<<<<<< HEAD from crm.serializers import CRMActivitySerializer, CRMProspectSerializer, CRMEntitySerializer from crm.models import Activity, Prospect, Entity @@ -28,8 +27,6 @@ from rest_framework.exceptions import MethodNotAllowed from django.http import Http404 from django.db.models import Q -======= ->>>>>>> f988fc1c06ccb26ea16383304e65161733a71fca from .permissions import IsClubOwner from .utils import check_version_smaller_than_1_1_12, scrape_fft_club_tournaments, scrape_fft_club_tournaments_all_pages, get_umpire_data, scrape_fft_all_tournaments From edead66c2ec0a856f9691aeaac24814e61f470a9 Mon Sep 17 00:00:00 2001 From: laurent Date: Wed, 9 Jul 2025 16:57:09 +0200 Subject: [PATCH 3/7] timezone migration --- .../migrations/0133_alter_club_timezone.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tournaments/migrations/0133_alter_club_timezone.py diff --git a/tournaments/migrations/0133_alter_club_timezone.py b/tournaments/migrations/0133_alter_club_timezone.py new file mode 100644 index 0000000..ff7033c --- /dev/null +++ b/tournaments/migrations/0133_alter_club_timezone.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2025-07-09 14:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0132_alter_purchase_user'), + ] + + operations = [ + migrations.AlterField( + model_name='club', + name='timezone', + field=models.CharField(blank=True, choices=[('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis_Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmara', 'Africa/Asmara'), ('Africa/Asmera', 'Africa/Asmera'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El_Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao_Tome'), ('Africa/Timbuktu', 'Africa/Timbuktu'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos_Aires'), ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), ('America/Argentina/ComodRivadavia', 'America/Argentina/ComodRivadavia'), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), ('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'), ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San_Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San_Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Atikokan', 'America/Atikokan'), ('America/Atka', 'America/Atka'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia_Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa_Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Buenos_Aires', 'America/Buenos_Aires'), ('America/Cambridge_Bay', 'America/Cambridge_Bay'), ('America/Campo_Grande', 'America/Campo_Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Catamarca', 'America/Catamarca'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Ciudad_Juarez', 'America/Ciudad_Juarez'), ('America/Coral_Harbour', 'America/Coral_Harbour'), ('America/Cordoba', 'America/Cordoba'), ('America/Costa_Rica', 'America/Costa_Rica'), ('America/Coyhaique', 'America/Coyhaique'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson_Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El_Salvador'), ('America/Ensenada', 'America/Ensenada'), ('America/Fort_Nelson', 'America/Fort_Nelson'), ('America/Fort_Wayne', 'America/Fort_Wayne'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace_Bay'), ('America/Godthab', 'America/Godthab'), ('America/Goose_Bay', 'America/Goose_Bay'), ('America/Grand_Turk', 'America/Grand_Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell_City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Indianapolis', 'America/Indianapolis'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Jujuy', 'America/Jujuy'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Knox_IN', 'America/Knox_IN'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La_Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los_Angeles'), ('America/Louisville', 'America/Louisville'), ('America/Lower_Princes', 'America/Lower_Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Mendoza', 'America/Mendoza'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico_City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montreal', 'America/Montreal'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New_York'), ('America/Nipigon', 'America/Nipigon'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North_Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'), ('America/Nuuk', 'America/Nuuk'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Pangnirtung', 'America/Pangnirtung'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port_of_Spain'), ('America/Porto_Acre', 'America/Porto_Acre'), ('America/Porto_Velho', 'America/Porto_Velho'), ('America/Puerto_Rico', 'America/Puerto_Rico'), ('America/Punta_Arenas', 'America/Punta_Arenas'), ('America/Rainy_River', 'America/Rainy_River'), ('America/Rankin_Inlet', 'America/Rankin_Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio_Branco'), ('America/Rosario', 'America/Rosario'), ('America/Santa_Isabel', 'America/Santa_Isabel'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo_Domingo'), ('America/Sao_Paulo', 'America/Sao_Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Shiprock', 'America/Shiprock'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St_Barthelemy'), ('America/St_Johns', 'America/St_Johns'), ('America/St_Kitts', 'America/St_Kitts'), ('America/St_Lucia', 'America/St_Lucia'), ('America/St_Thomas', 'America/St_Thomas'), ('America/St_Vincent', 'America/St_Vincent'), ('America/Swift_Current', 'America/Swift_Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Thunder_Bay', 'America/Thunder_Bay'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Virgin', 'America/Virgin'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('America/Yellowknife', 'America/Yellowknife'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/South_Pole', 'Antarctica/South_Pole'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Ashkhabad', 'Asia/Ashkhabad'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Calcutta', 'Asia/Calcutta'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Choibalsan', 'Asia/Choibalsan'), ('Asia/Chongqing', 'Asia/Chongqing'), ('Asia/Chungking', 'Asia/Chungking'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Dacca', 'Asia/Dacca'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Harbin', 'Asia/Harbin'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Ho_Chi_Minh', 'Asia/Ho_Chi_Minh'), ('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Istanbul', 'Asia/Istanbul'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kashgar', 'Asia/Kashgar'), ('Asia/Kathmandu', 'Asia/Kathmandu'), ('Asia/Katmandu', 'Asia/Katmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Kolkata', 'Asia/Kolkata'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macao', 'Asia/Macao'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Rangoon', 'Asia/Rangoon'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Saigon', 'Asia/Saigon'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Tel_Aviv', 'Asia/Tel_Aviv'), ('Asia/Thimbu', 'Asia/Thimbu'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ujung_Pandang', 'Asia/Ujung_Pandang'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Ulan_Bator', 'Asia/Ulan_Bator'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'), ('Atlantic/Faeroe', 'Atlantic/Faeroe'), ('Atlantic/Faroe', 'Atlantic/Faroe'), ('Atlantic/Jan_Mayen', 'Atlantic/Jan_Mayen'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South_Georgia'), ('Atlantic/St_Helena', 'Atlantic/St_Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/ACT', 'Australia/ACT'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken_Hill'), ('Australia/Canberra', 'Australia/Canberra'), ('Australia/Currie', 'Australia/Currie'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/LHI', 'Australia/LHI'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord_Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/NSW', 'Australia/NSW'), ('Australia/North', 'Australia/North'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Queensland', 'Australia/Queensland'), ('Australia/South', 'Australia/South'), ('Australia/Sydney', 'Australia/Sydney'), ('Australia/Tasmania', 'Australia/Tasmania'), ('Australia/Victoria', 'Australia/Victoria'), ('Australia/West', 'Australia/West'), ('Australia/Yancowinna', 'Australia/Yancowinna'), ('Brazil/Acre', 'Brazil/Acre'), ('Brazil/DeNoronha', 'Brazil/DeNoronha'), ('Brazil/East', 'Brazil/East'), ('Brazil/West', 'Brazil/West'), ('CET', 'CET'), ('CST6CDT', 'CST6CDT'), ('Canada/Atlantic', 'Canada/Atlantic'), ('Canada/Central', 'Canada/Central'), ('Canada/Eastern', 'Canada/Eastern'), ('Canada/Mountain', 'Canada/Mountain'), ('Canada/Newfoundland', 'Canada/Newfoundland'), ('Canada/Pacific', 'Canada/Pacific'), ('Canada/Saskatchewan', 'Canada/Saskatchewan'), ('Canada/Yukon', 'Canada/Yukon'), ('Chile/Continental', 'Chile/Continental'), ('Chile/EasterIsland', 'Chile/EasterIsland'), ('Cuba', 'Cuba'), ('EET', 'EET'), ('EST', 'EST'), ('EST5EDT', 'EST5EDT'), ('Egypt', 'Egypt'), ('Eire', 'Eire'), ('Etc/GMT', 'Etc/GMT'), ('Etc/GMT+0', 'Etc/GMT+0'), ('Etc/GMT+1', 'Etc/GMT+1'), ('Etc/GMT+10', 'Etc/GMT+10'), ('Etc/GMT+11', 'Etc/GMT+11'), ('Etc/GMT+12', 'Etc/GMT+12'), ('Etc/GMT+2', 'Etc/GMT+2'), ('Etc/GMT+3', 'Etc/GMT+3'), ('Etc/GMT+4', 'Etc/GMT+4'), ('Etc/GMT+5', 'Etc/GMT+5'), ('Etc/GMT+6', 'Etc/GMT+6'), ('Etc/GMT+7', 'Etc/GMT+7'), ('Etc/GMT+8', 'Etc/GMT+8'), ('Etc/GMT+9', 'Etc/GMT+9'), ('Etc/GMT-0', 'Etc/GMT-0'), ('Etc/GMT-1', 'Etc/GMT-1'), ('Etc/GMT-10', 'Etc/GMT-10'), ('Etc/GMT-11', 'Etc/GMT-11'), ('Etc/GMT-12', 'Etc/GMT-12'), ('Etc/GMT-13', 'Etc/GMT-13'), ('Etc/GMT-14', 'Etc/GMT-14'), ('Etc/GMT-2', 'Etc/GMT-2'), ('Etc/GMT-3', 'Etc/GMT-3'), ('Etc/GMT-4', 'Etc/GMT-4'), ('Etc/GMT-5', 'Etc/GMT-5'), ('Etc/GMT-6', 'Etc/GMT-6'), ('Etc/GMT-7', 'Etc/GMT-7'), ('Etc/GMT-8', 'Etc/GMT-8'), ('Etc/GMT-9', 'Etc/GMT-9'), ('Etc/GMT0', 'Etc/GMT0'), ('Etc/Greenwich', 'Etc/Greenwich'), ('Etc/UCT', 'Etc/UCT'), ('Etc/UTC', 'Etc/UTC'), ('Etc/Universal', 'Etc/Universal'), ('Etc/Zulu', 'Etc/Zulu'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belfast', 'Europe/Belfast'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle_of_Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Kyiv', 'Europe/Kyiv'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Nicosia', 'Europe/Nicosia'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San_Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Tiraspol', 'Europe/Tiraspol'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Uzhgorod', 'Europe/Uzhgorod'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zaporozhye', 'Europe/Zaporozhye'), ('Europe/Zurich', 'Europe/Zurich'), ('Factory', 'Factory'), ('GB', 'GB'), ('GB-Eire', 'GB-Eire'), ('GMT', 'GMT'), ('GMT+0', 'GMT+0'), ('GMT-0', 'GMT-0'), ('GMT0', 'GMT0'), ('Greenwich', 'Greenwich'), ('HST', 'HST'), ('Hongkong', 'Hongkong'), ('Iceland', 'Iceland'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Iran', 'Iran'), ('Israel', 'Israel'), ('Jamaica', 'Jamaica'), ('Japan', 'Japan'), ('Kwajalein', 'Kwajalein'), ('Libya', 'Libya'), ('MET', 'MET'), ('MST', 'MST'), ('MST7MDT', 'MST7MDT'), ('Mexico/BajaNorte', 'Mexico/BajaNorte'), ('Mexico/BajaSur', 'Mexico/BajaSur'), ('Mexico/General', 'Mexico/General'), ('NZ', 'NZ'), ('NZ-CHAT', 'NZ-CHAT'), ('Navajo', 'Navajo'), ('PRC', 'PRC'), ('PST8PDT', 'PST8PDT'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Chuuk', 'Pacific/Chuuk'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Enderbury', 'Pacific/Enderbury'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Johnston', 'Pacific/Johnston'), ('Pacific/Kanton', 'Pacific/Kanton'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago_Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Pohnpei', 'Pacific/Pohnpei'), ('Pacific/Ponape', 'Pacific/Ponape'), ('Pacific/Port_Moresby', 'Pacific/Port_Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Samoa', 'Pacific/Samoa'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Truk', 'Pacific/Truk'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis'), ('Pacific/Yap', 'Pacific/Yap'), ('Poland', 'Poland'), ('Portugal', 'Portugal'), ('ROC', 'ROC'), ('ROK', 'ROK'), ('Singapore', 'Singapore'), ('Turkey', 'Turkey'), ('UCT', 'UCT'), ('US/Alaska', 'US/Alaska'), ('US/Aleutian', 'US/Aleutian'), ('US/Arizona', 'US/Arizona'), ('US/Central', 'US/Central'), ('US/East-Indiana', 'US/East-Indiana'), ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), ('US/Indiana-Starke', 'US/Indiana-Starke'), ('US/Michigan', 'US/Michigan'), ('US/Mountain', 'US/Mountain'), ('US/Pacific', 'US/Pacific'), ('US/Samoa', 'US/Samoa'), ('UTC', 'UTC'), ('Universal', 'Universal'), ('W-SU', 'W-SU'), ('WET', 'WET'), ('Zulu', 'Zulu'), ('localtime', 'localtime')], default='CET', max_length=50, null=True), + ), + ] From 4c1ebaf78022100990866a0b05e0ac3bd0454dab Mon Sep 17 00:00:00 2001 From: laurent Date: Wed, 9 Jul 2025 17:03:17 +0200 Subject: [PATCH 4/7] crm migration --- crm/migrations/0001_initial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crm/migrations/0001_initial.py b/crm/migrations/0001_initial.py index 53b1496..318c5fb 100644 --- a/crm/migrations/0001_initial.py +++ b/crm/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1 on 2025-07-09 13:33 +# Generated by Django 5.1 on 2025-07-09 15:02 import django.db.models.deletion import django.utils.timezone From 9db872e35cb157a4ef168e667ccae630859dc34e Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 9 Jul 2025 17:22:08 +0200 Subject: [PATCH 5/7] renamed crm into bizdev --- api/urls.py | 2 +- api/views.py | 6 +-- {crm => bizdev}/__init__.py | 0 bizdev/_instructions/base.md | 1 + {crm => bizdev}/admin.py | 20 +++---- {crm => bizdev}/admin_urls.py | 6 +-- {crm => bizdev}/apps.py | 4 +- {crm => bizdev}/filters.py | 0 {crm => bizdev}/forms.py | 0 {crm => bizdev}/migrations/0001_initial.py | 8 +-- {crm => bizdev}/migrations/__init__.py | 0 {crm => bizdev}/mixins.py | 4 +- {crm => bizdev}/models.py | 0 {crm => bizdev}/serializers.py | 0 {crm => bizdev}/services.py | 0 {crm => bizdev}/static/crm/js/prospects.js | 0 .../templates/admin/bizdev}/app_index.html | 8 +-- .../templates/admin/bizdev}/email_users.html | 0 .../admin/bizdev}/prospect/change_list.html | 0 .../admin/bizdev}/prospect/import_file.html | 0 .../admin/bizdev}/select_email_template.html | 0 .../templates/bizdev}/add_prospect.html | 2 +- .../crm => bizdev/templates/bizdev}/base.html | 0 .../templates/bizdev}/csv_import.html | 2 +- .../templates/bizdev}/event_form.html | 4 +- .../templates/bizdev}/event_row.html | 4 +- .../templates/bizdev}/events.html | 18 +++---- .../templates/bizdev}/prospect_form.html | 2 +- .../templates/bizdev}/prospect_list.html | 14 ++--- .../templates/bizdev}/send_bulk_email.html | 4 +- {crm => bizdev}/templatetags/__init__.py | 0 bizdev/templatetags/crm_tags.py | 7 +++ {crm => bizdev}/tests.py | 0 {crm => bizdev}/urls.py | 2 +- {crm => bizdev}/views.py | 54 +++++++++---------- crm/_instructions/base.md | 1 - crm/templatetags/crm_tags.py | 7 --- padelclub_backend/settings.py | 2 +- padelclub_backend/urls.py | 2 +- .../migrations/0134_alter_club_timezone.py | 18 +++++++ 40 files changed, 110 insertions(+), 92 deletions(-) rename {crm => bizdev}/__init__.py (100%) create mode 100644 bizdev/_instructions/base.md rename {crm => bizdev}/admin.py (96%) rename {crm => bizdev}/admin_urls.py (91%) rename {crm => bizdev}/apps.py (64%) rename {crm => bizdev}/filters.py (100%) rename {crm => bizdev}/forms.py (100%) rename {crm => bizdev}/migrations/0001_initial.py (97%) rename {crm => bizdev}/migrations/__init__.py (100%) rename {crm => bizdev}/mixins.py (51%) rename {crm => bizdev}/models.py (100%) rename {crm => bizdev}/serializers.py (100%) rename {crm => bizdev}/services.py (100%) rename {crm => bizdev}/static/crm/js/prospects.js (100%) rename {crm/templates/admin/crm => bizdev/templates/admin/bizdev}/app_index.html (61%) rename {crm/templates/admin/crm => bizdev/templates/admin/bizdev}/email_users.html (100%) rename {crm/templates/admin/crm => bizdev/templates/admin/bizdev}/prospect/change_list.html (100%) rename {crm/templates/admin/crm => bizdev/templates/admin/bizdev}/prospect/import_file.html (100%) rename {crm/templates/admin/crm => bizdev/templates/admin/bizdev}/select_email_template.html (100%) rename {crm/templates/crm => bizdev/templates/bizdev}/add_prospect.html (98%) rename {crm/templates/crm => bizdev/templates/bizdev}/base.html (100%) rename {crm/templates/crm => bizdev/templates/bizdev}/csv_import.html (96%) rename {crm/templates/crm => bizdev/templates/bizdev}/event_form.html (85%) rename {crm/templates/crm => bizdev/templates/bizdev}/event_row.html (71%) rename {crm/templates/crm => bizdev/templates/bizdev}/events.html (66%) rename {crm/templates/crm => bizdev/templates/bizdev}/prospect_form.html (93%) rename {crm/templates/crm => bizdev/templates/bizdev}/prospect_list.html (81%) rename {crm/templates/crm => bizdev/templates/bizdev}/send_bulk_email.html (91%) rename {crm => bizdev}/templatetags/__init__.py (100%) create mode 100644 bizdev/templatetags/crm_tags.py rename {crm => bizdev}/tests.py (100%) rename {crm => bizdev}/urls.py (97%) rename {crm => bizdev}/views.py (84%) delete mode 100644 crm/_instructions/base.md delete mode 100644 crm/templatetags/crm_tags.py create mode 100644 tournaments/migrations/0134_alter_club_timezone.py diff --git a/api/urls.py b/api/urls.py index 072e52b..13a76a6 100644 --- a/api/urls.py +++ b/api/urls.py @@ -29,7 +29,7 @@ router.register(r'device-token', views.DeviceTokenViewSet) router.register(r'data-access', DataAccessViewSet) router.register(r'unregistered-teams', views.UnregisteredTeamViewSet) router.register(r'unregistered-players', views.UnregisteredPlayerViewSet) -### CRM +### bizdev router.register(r'crm-prospects', views.CRMProspectViewSet) router.register(r'crm-entities', views.CRMEntityViewSet) router.register(r'crm-activities', views.CRMActivityViewSet) diff --git a/api/views.py b/api/views.py index c69c63a..db4b1e3 100644 --- a/api/views.py +++ b/api/views.py @@ -15,8 +15,8 @@ from django.shortcuts import get_object_or_404 from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer, ImageSerializer from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image -from crm.serializers import CRMActivitySerializer, CRMProspectSerializer, CRMEntitySerializer -from crm.models import Activity, Prospect, Entity +from bizdev.serializers import CRMActivitySerializer, CRMProspectSerializer, CRMEntitySerializer +from bizdev.models import Activity, Prospect, Entity from rest_framework import viewsets from rest_framework.response import Response @@ -887,7 +887,7 @@ def get_fft_club_tournaments_with_umpire_data(request): 'message': f'Unexpected error: {str(e)}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) -### CRM +### bizdev class CRMActivityViewSet(SoftDeleteViewSet): queryset = Activity.objects.all() serializer_class = CRMActivitySerializer diff --git a/crm/__init__.py b/bizdev/__init__.py similarity index 100% rename from crm/__init__.py rename to bizdev/__init__.py diff --git a/bizdev/_instructions/base.md b/bizdev/_instructions/base.md new file mode 100644 index 0000000..d6d8c2b --- /dev/null +++ b/bizdev/_instructions/base.md @@ -0,0 +1 @@ +This is a django customer relationship managemement app. diff --git a/crm/admin.py b/bizdev/admin.py similarity index 96% rename from crm/admin.py rename to bizdev/admin.py index 5b72094..79d6735 100644 --- a/crm/admin.py +++ b/bizdev/admin.py @@ -77,7 +77,7 @@ def create_activity_for_prospect(modeladmin, request, queryset): prospect = queryset.first() # Build the URL with pre-populated fields - url = reverse('admin:crm_activity_add') + url = reverse('admin:bizdev_activity_add') url += f'?prospect={prospect.id}' # You can add more fields as URL parameters @@ -98,7 +98,7 @@ class ProspectAdmin(SyncedObjectAdmin): list_filter = ('creation_date', StaffUserFilter, 'source', ProspectProfileFilter) search_fields = ('first_name', 'last_name', 'email') date_hierarchy = 'creation_date' - change_list_template = "admin/crm/prospect/change_list.html" + 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] @@ -108,7 +108,7 @@ class ProspectAdmin(SyncedObjectAdmin): if events: event_links = [] for event in events: - url = f"/kingdom/crm/event/{event.id}/change/" + url = f"/kingdom/bizdev/event/{event.id}/change/" event_links.append(f'{event.html_desc()}') return format_html('
    '.join(event_links)) return "No events" @@ -118,7 +118,7 @@ class ProspectAdmin(SyncedObjectAdmin): 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:crm_prospect_changelist'), + reverse('admin:bizdev_prospect_changelist'), request.user.id ) extra_context = extra_context or {} @@ -139,8 +139,8 @@ class ProspectAdmin(SyncedObjectAdmin): Prospect.objects.all().delete() Activity.objects.all().delete() - messages.success(request, 'cleanup CRM objects') - return redirect('admin:crm_prospect_changelist') + 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) @@ -177,7 +177,7 @@ class ProspectAdmin(SyncedObjectAdmin): created_count += 1 messages.success(request, f'Imported {created_count} app users into prospects') - return redirect('admin:crm_prospect_changelist') + return redirect('admin:bizdev_prospect_changelist') def import_file(self, request): """ @@ -190,7 +190,7 @@ class ProspectAdmin(SyncedObjectAdmin): try: result = self.import_csv(form.cleaned_data['file'], form.cleaned_data['source']) messages.success(request, f'File imported successfully: {result}') - return redirect('admin:crm_prospect_changelist') + return redirect('admin:bizdev_prospect_changelist') except Exception as e: messages.error(request, f'Error importing file: {str(e)}') else: @@ -205,7 +205,7 @@ class ProspectAdmin(SyncedObjectAdmin): 'opts': self.model._meta, 'has_change_permission': self.has_change_permission(request), } - return render(request, 'admin/crm/prospect/import_file.html', context) + return render(request, 'admin/bizdev/prospect/import_file.html', context) def import_csv(self, file, source): """ @@ -336,7 +336,7 @@ class ProspectAdmin(SyncedObjectAdmin): else: form = EmailTemplateSelectionForm() - return render(request, 'admin/crm/select_email_template.html', { + return render(request, 'admin/bizdev/select_email_template.html', { 'prospects': queryset, 'form': form, 'title': 'Send Email to Prospects' diff --git a/crm/admin_urls.py b/bizdev/admin_urls.py similarity index 91% rename from crm/admin_urls.py rename to bizdev/admin_urls.py index 5d94c6e..76b42c7 100644 --- a/crm/admin_urls.py +++ b/bizdev/admin_urls.py @@ -63,8 +63,8 @@ def template(user, index): 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='crm_email_users'), - path('email_users_count/', email_users_count, name='crm_email_count'), - path('email_users_with_tournaments_count/', email_users_with_tournaments_count, name='crm_email_with_tournaments_count'), + 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'), ] diff --git a/crm/apps.py b/bizdev/apps.py similarity index 64% rename from crm/apps.py rename to bizdev/apps.py index 7593b92..deacc18 100644 --- a/crm/apps.py +++ b/bizdev/apps.py @@ -1,5 +1,5 @@ from django.apps import AppConfig -class CrmConfig(AppConfig): +class bizdevConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'crm' + name = 'bizdev' diff --git a/crm/filters.py b/bizdev/filters.py similarity index 100% rename from crm/filters.py rename to bizdev/filters.py diff --git a/crm/forms.py b/bizdev/forms.py similarity index 100% rename from crm/forms.py rename to bizdev/forms.py diff --git a/crm/migrations/0001_initial.py b/bizdev/migrations/0001_initial.py similarity index 97% rename from crm/migrations/0001_initial.py rename to bizdev/migrations/0001_initial.py index 53b1496..abdc3d9 100644 --- a/crm/migrations/0001_initial.py +++ b/bizdev/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1 on 2025-07-09 13:33 +# Generated by Django 5.1 on 2025-07-09 15:18 import django.db.models.deletion import django.utils.timezone @@ -45,7 +45,7 @@ class Migration(migrations.Migration): ('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='crm.activity')), + ('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)), ], @@ -85,7 +85,7 @@ class Migration(migrations.Migration): ('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='crm.entity')), + ('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)), @@ -97,6 +97,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='activity', name='prospects', - field=models.ManyToManyField(related_name='activities', to='crm.prospect'), + field=models.ManyToManyField(related_name='activities', to='bizdev.prospect'), ), ] diff --git a/crm/migrations/__init__.py b/bizdev/migrations/__init__.py similarity index 100% rename from crm/migrations/__init__.py rename to bizdev/migrations/__init__.py diff --git a/crm/mixins.py b/bizdev/mixins.py similarity index 51% rename from crm/mixins.py rename to bizdev/mixins.py index debe594..dac9358 100644 --- a/crm/mixins.py +++ b/bizdev/mixins.py @@ -1,6 +1,6 @@ from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin from django.core.exceptions import PermissionDenied -class CRMAccessMixin(LoginRequiredMixin, UserPassesTestMixin): +class bizdevAccessMixin(LoginRequiredMixin, UserPassesTestMixin): def test_func(self): - return self.request.user.groups.filter(name='CRM Manager').exists() + return self.request.user.groups.filter(name='bizdev Manager').exists() diff --git a/crm/models.py b/bizdev/models.py similarity index 100% rename from crm/models.py rename to bizdev/models.py diff --git a/crm/serializers.py b/bizdev/serializers.py similarity index 100% rename from crm/serializers.py rename to bizdev/serializers.py diff --git a/crm/services.py b/bizdev/services.py similarity index 100% rename from crm/services.py rename to bizdev/services.py diff --git a/crm/static/crm/js/prospects.js b/bizdev/static/crm/js/prospects.js similarity index 100% rename from crm/static/crm/js/prospects.js rename to bizdev/static/crm/js/prospects.js diff --git a/crm/templates/admin/crm/app_index.html b/bizdev/templates/admin/bizdev/app_index.html similarity index 61% rename from crm/templates/admin/crm/app_index.html rename to bizdev/templates/admin/bizdev/app_index.html index 0da9f93..9c83f1a 100644 --- a/crm/templates/admin/crm/app_index.html +++ b/bizdev/templates/admin/bizdev/app_index.html @@ -1,4 +1,4 @@ - + {% extends "admin/app_index.html" %} {% load i18n %} @@ -6,13 +6,13 @@
    - + {% trans "Count Users no event" %} - + {% trans "Insta send email no event" %} - + {% trans "Count Users" %} diff --git a/crm/templates/admin/crm/email_users.html b/bizdev/templates/admin/bizdev/email_users.html similarity index 100% rename from crm/templates/admin/crm/email_users.html rename to bizdev/templates/admin/bizdev/email_users.html diff --git a/crm/templates/admin/crm/prospect/change_list.html b/bizdev/templates/admin/bizdev/prospect/change_list.html similarity index 100% rename from crm/templates/admin/crm/prospect/change_list.html rename to bizdev/templates/admin/bizdev/prospect/change_list.html diff --git a/crm/templates/admin/crm/prospect/import_file.html b/bizdev/templates/admin/bizdev/prospect/import_file.html similarity index 100% rename from crm/templates/admin/crm/prospect/import_file.html rename to bizdev/templates/admin/bizdev/prospect/import_file.html diff --git a/crm/templates/admin/crm/select_email_template.html b/bizdev/templates/admin/bizdev/select_email_template.html similarity index 100% rename from crm/templates/admin/crm/select_email_template.html rename to bizdev/templates/admin/bizdev/select_email_template.html diff --git a/crm/templates/crm/add_prospect.html b/bizdev/templates/bizdev/add_prospect.html similarity index 98% rename from crm/templates/crm/add_prospect.html rename to bizdev/templates/bizdev/add_prospect.html index 53a667a..a74e4d9 100644 --- a/crm/templates/crm/add_prospect.html +++ b/bizdev/templates/bizdev/add_prospect.html @@ -1,4 +1,4 @@ -{% extends "crm/base.html" %} +{% extends "bizdev/base.html" %} {% block content %}
    diff --git a/crm/templates/crm/base.html b/bizdev/templates/bizdev/base.html similarity index 100% rename from crm/templates/crm/base.html rename to bizdev/templates/bizdev/base.html diff --git a/crm/templates/crm/csv_import.html b/bizdev/templates/bizdev/csv_import.html similarity index 96% rename from crm/templates/crm/csv_import.html rename to bizdev/templates/bizdev/csv_import.html index 5041a0a..44c3ab1 100644 --- a/crm/templates/crm/csv_import.html +++ b/bizdev/templates/bizdev/csv_import.html @@ -1,4 +1,4 @@ -{% extends "crm/base.html" %} +{% extends "bizdev/base.html" %} {% block content %}
    diff --git a/crm/templates/crm/event_form.html b/bizdev/templates/bizdev/event_form.html similarity index 85% rename from crm/templates/crm/event_form.html rename to bizdev/templates/bizdev/event_form.html index 534b611..74f2a10 100644 --- a/crm/templates/crm/event_form.html +++ b/bizdev/templates/bizdev/event_form.html @@ -1,4 +1,4 @@ -{% extends "crm/base.html" %} {% block content %} +{% extends "bizdev/base.html" %} {% block content %}
    @@ -14,7 +14,7 @@ Save Event Cancel diff --git a/crm/templates/crm/event_row.html b/bizdev/templates/bizdev/event_row.html similarity index 71% rename from crm/templates/crm/event_row.html rename to bizdev/templates/bizdev/event_row.html index 2042fef..a37fc7b 100644 --- a/crm/templates/crm/event_row.html +++ b/bizdev/templates/bizdev/event_row.html @@ -7,9 +7,9 @@
    {{ event.date|date:"d/m/Y H:i" }} - Edit + Edit
    diff --git a/crm/templates/crm/events.html b/bizdev/templates/bizdev/events.html similarity index 66% rename from crm/templates/crm/events.html rename to bizdev/templates/bizdev/events.html index 372ff23..feda717 100644 --- a/crm/templates/crm/events.html +++ b/bizdev/templates/bizdev/events.html @@ -1,23 +1,23 @@ -{% extends "crm/base.html" %} -{% load crm_tags %} +{% extends "bizdev/base.html" %} +{% load bizdev_tags %} {% block content %} -{% if request.user|is_crm_manager %} +{% if request.user|is_bizdev_manager %} @@ -31,7 +31,7 @@
    {% for event in completed_events %} - {% include "crm/event_row.html" with event=event %} + {% include "bizdev/event_row.html" with event=event %} {% empty %}
    No completed events.
    {% endfor %} @@ -47,7 +47,7 @@
    {% for event in planned_events %} - {% include "crm/event_row.html" with event=event %} + {% include "bizdev/event_row.html" with event=event %} {% empty %}
    No planned events.
    {% endfor %} diff --git a/crm/templates/crm/prospect_form.html b/bizdev/templates/bizdev/prospect_form.html similarity index 93% rename from crm/templates/crm/prospect_form.html rename to bizdev/templates/bizdev/prospect_form.html index 334e8bf..c22dcd0 100644 --- a/crm/templates/crm/prospect_form.html +++ b/bizdev/templates/bizdev/prospect_form.html @@ -1,4 +1,4 @@ -{% extends "crm/base.html" %} +{% extends "bizdev/base.html" %} {% block head_title %}{{ first_title }}{% endblock %} {% block first_title %}{{ first_title }}{% endblock %} diff --git a/crm/templates/crm/prospect_list.html b/bizdev/templates/bizdev/prospect_list.html similarity index 81% rename from crm/templates/crm/prospect_list.html rename to bizdev/templates/bizdev/prospect_list.html index 0068867..7ccf716 100644 --- a/crm/templates/crm/prospect_list.html +++ b/bizdev/templates/bizdev/prospect_list.html @@ -1,4 +1,4 @@ -{% extends "crm/base.html" %} +{% extends "bizdev/base.html" %} {% load static %} @@ -18,15 +18,15 @@ {% endfor %}
    - Clear + Clear
    {{ filter.qs|length }} résultats @@ -60,11 +60,11 @@ {% endfor %} - + - + @@ -77,5 +77,5 @@ {% endblock %} {% block extra_js %} - + {% endblock %} diff --git a/crm/templates/crm/send_bulk_email.html b/bizdev/templates/bizdev/send_bulk_email.html similarity index 91% rename from crm/templates/crm/send_bulk_email.html rename to bizdev/templates/bizdev/send_bulk_email.html index 53bdfac..f1b9200 100644 --- a/crm/templates/crm/send_bulk_email.html +++ b/bizdev/templates/bizdev/send_bulk_email.html @@ -1,4 +1,4 @@ -{% extends "crm/base.html" %} +{% extends "bizdev/base.html" %} {% block content %}
    @@ -41,7 +41,7 @@
    - Cancel + Cancel
    {% endblock %} diff --git a/crm/templatetags/__init__.py b/bizdev/templatetags/__init__.py similarity index 100% rename from crm/templatetags/__init__.py rename to bizdev/templatetags/__init__.py diff --git a/bizdev/templatetags/crm_tags.py b/bizdev/templatetags/crm_tags.py new file mode 100644 index 0000000..4b0ee8e --- /dev/null +++ b/bizdev/templatetags/crm_tags.py @@ -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() diff --git a/crm/tests.py b/bizdev/tests.py similarity index 100% rename from crm/tests.py rename to bizdev/tests.py diff --git a/crm/urls.py b/bizdev/urls.py similarity index 97% rename from crm/urls.py rename to bizdev/urls.py index adbd5e6..beceab4 100644 --- a/crm/urls.py +++ b/bizdev/urls.py @@ -1,7 +1,7 @@ from django.urls import path from . import views -app_name = 'crm' +app_name = 'bizdev' urlpatterns = [ path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='events'), diff --git a/crm/views.py b/bizdev/views.py similarity index 84% rename from crm/views.py rename to bizdev/views.py index 93b74a8..c170a71 100644 --- a/crm/views.py +++ b/bizdev/views.py @@ -19,13 +19,13 @@ from .models import Event, Prospect, ActivityType from .filters import ProspectFilter from .forms import CSVImportForm -from .mixins import CRMAccessMixin +from .mixins import bizdevAccessMixin import csv from io import TextIOWrapper from datetime import datetime -# @permission_required('crm.view_crm', raise_exception=True) +# @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 @@ -42,7 +42,7 @@ from datetime import datetime # action = 'updated' if pk else 'added' # messages.success(request, # f'Prospect {prospect.entity_name} has been {action} successfully!') -# return redirect('crm:events') +# return redirect('bizdev:events') # else: # form = ProspectForm(instance=prospect) @@ -52,9 +52,9 @@ from datetime import datetime # '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) +# return render(request, 'bizdev/prospect_form.html', context) -# # @permission_required('crm.view_crm', raise_exception=True) +# # @permission_required('bizdev.view_bizdev', raise_exception=True) # # def add_prospect(request): # # if request.method == 'POST': # # entity_name = request.POST.get('entity_name') @@ -82,17 +82,17 @@ from datetime import datetime # # 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 +# # 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, 'crm/add_prospect.html') +# # return render(request, 'bizdev/add_prospect.html') -# class EventCreateView(CRMAccessMixin, CreateView): +# class EventCreateView(bizdevAccessMixin, CreateView): # model = Event # form_class = EventForm -# template_name = 'crm/event_form.html' -# success_url = reverse_lazy('crm:planned_events') +# template_name = 'bizdev/event_form.html' +# success_url = reverse_lazy('bizdev:planned_events') # def get_initial(self): # initial = super().get_initial() @@ -106,11 +106,11 @@ from datetime import datetime # form.instance.modified_by = self.request.user # return super().form_valid(form) -# class EditEventView(CRMAccessMixin, UpdateView): +# class EditEventView(bizdevAccessMixin, UpdateView): # model = Event # form_class = EventForm -# template_name = 'crm/event_form.html' -# success_url = reverse_lazy('crm:planned_events') +# 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 @@ -118,7 +118,7 @@ from datetime import datetime # messages.success(self.request, 'Event updated successfully!') # return response -# class StartEventView(CRMAccessMixin, BaseUpdateView): +# class StartEventView(bizdevAccessMixin, BaseUpdateView): # model = Event # http_method_names = ['post', 'get'] @@ -132,23 +132,23 @@ from datetime import datetime # if event.type == 'MAIL': # return HttpResponseRedirect( -# reverse_lazy('crm:setup_email_campaign', kwargs={'event_id': event.id}) +# reverse_lazy('bizdev: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}) +# reverse_lazy('bizdev: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}) +# reverse_lazy('bizdev:setup_press_release', kwargs={'event_id': event.id}) # ) # messages.success(request, 'Event started successfully!') -# return HttpResponseRedirect(reverse_lazy('crm:planned_events')) +# return HttpResponseRedirect(reverse_lazy('bizdev:planned_events')) -# class EventListView(CRMAccessMixin, ListView): +# class EventListView(bizdevAccessMixin, ListView): # model = Event -# template_name = 'crm/events.html' +# 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): @@ -161,9 +161,9 @@ from datetime import datetime # ).order_by('-date') # return context -# class ProspectListView(CRMAccessMixin, ListView): +# class ProspectListView(bizdevAccessMixin, ListView): # model = Prospect -# template_name = 'crm/prospect_list.html' +# template_name = 'bizdev/prospect_list.html' # context_object_name = 'prospects' # filterset_class = ProspectFilter @@ -178,8 +178,8 @@ from datetime import datetime # ) # return context -# class CSVImportView(CRMAccessMixin, FormView): -# template_name = 'crm/csv_import.html' +# class CSVImportView(bizdevAccessMixin, FormView): +# template_name = 'bizdev/csv_import.html' # form_class = CSVImportForm # success_url = reverse_lazy('prospect-list') @@ -247,10 +247,10 @@ from datetime import datetime # return super().form_valid(form) -# class SendBulkEmailView(CRMAccessMixin, FormView): -# template_name = 'crm/send_bulk_email.html' +# class SendBulkEmailView(bizdevAccessMixin, FormView): +# template_name = 'bizdev/send_bulk_email.html' # form_class = BulkEmailForm -# success_url = reverse_lazy('crm:prospect-list') +# success_url = reverse_lazy('bizdev:prospect-list') # def form_valid(self, form): # prospects = form.cleaned_data['prospects'] diff --git a/crm/_instructions/base.md b/crm/_instructions/base.md deleted file mode 100644 index a45abd0..0000000 --- a/crm/_instructions/base.md +++ /dev/null @@ -1 +0,0 @@ -This is a django customer relationship managemement (CRM) app. diff --git a/crm/templatetags/crm_tags.py b/crm/templatetags/crm_tags.py deleted file mode 100644 index 9c39ee3..0000000 --- a/crm/templatetags/crm_tags.py +++ /dev/null @@ -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() diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py index 9d55d1b..9209ee4 100644 --- a/padelclub_backend/settings.py +++ b/padelclub_backend/settings.py @@ -36,7 +36,7 @@ INSTALLED_APPS = [ 'sync', 'tournaments', 'shop', - 'crm', + 'bizdev', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', diff --git a/padelclub_backend/urls.py b/padelclub_backend/urls.py index 7703e47..e4f5c5c 100644 --- a/padelclub_backend/urls.py +++ b/padelclub_backend/urls.py @@ -34,7 +34,7 @@ urlpatterns = [ path('kingdom/debug/player-license-lookup/', get_player_license_info, name='player_license_lookup'), path('kingdom/debug/bulk-license-lookup/', bulk_license_lookup, name='bulk_license_lookup'), path('kingdom/debug/explore-api-endpoints/', explore_fft_api_endpoints, name='explore_api_endpoints'), - path('kingdom/crm/', include('crm.admin_urls')), + path('kingdom/bizdev/', include('bizdev.admin_urls')), path('kingdom/', admin.site.urls), path('api-auth/', include('rest_framework.urls')), path('dj-auth/', include('django.contrib.auth.urls')), diff --git a/tournaments/migrations/0134_alter_club_timezone.py b/tournaments/migrations/0134_alter_club_timezone.py new file mode 100644 index 0000000..04cc7f6 --- /dev/null +++ b/tournaments/migrations/0134_alter_club_timezone.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2025-07-09 15:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0133_alter_club_timezone'), + ] + + operations = [ + migrations.AlterField( + model_name='club', + name='timezone', + field=models.CharField(blank=True, choices=[('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis_Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmara', 'Africa/Asmara'), ('Africa/Asmera', 'Africa/Asmera'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El_Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao_Tome'), ('Africa/Timbuktu', 'Africa/Timbuktu'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos_Aires'), ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), ('America/Argentina/ComodRivadavia', 'America/Argentina/ComodRivadavia'), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), ('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'), ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San_Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San_Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Atikokan', 'America/Atikokan'), ('America/Atka', 'America/Atka'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia_Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa_Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Buenos_Aires', 'America/Buenos_Aires'), ('America/Cambridge_Bay', 'America/Cambridge_Bay'), ('America/Campo_Grande', 'America/Campo_Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Catamarca', 'America/Catamarca'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Ciudad_Juarez', 'America/Ciudad_Juarez'), ('America/Coral_Harbour', 'America/Coral_Harbour'), ('America/Cordoba', 'America/Cordoba'), ('America/Costa_Rica', 'America/Costa_Rica'), ('America/Coyhaique', 'America/Coyhaique'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson_Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El_Salvador'), ('America/Ensenada', 'America/Ensenada'), ('America/Fort_Nelson', 'America/Fort_Nelson'), ('America/Fort_Wayne', 'America/Fort_Wayne'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace_Bay'), ('America/Godthab', 'America/Godthab'), ('America/Goose_Bay', 'America/Goose_Bay'), ('America/Grand_Turk', 'America/Grand_Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell_City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Indianapolis', 'America/Indianapolis'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Jujuy', 'America/Jujuy'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Knox_IN', 'America/Knox_IN'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La_Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los_Angeles'), ('America/Louisville', 'America/Louisville'), ('America/Lower_Princes', 'America/Lower_Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Mendoza', 'America/Mendoza'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico_City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montreal', 'America/Montreal'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New_York'), ('America/Nipigon', 'America/Nipigon'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North_Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'), ('America/Nuuk', 'America/Nuuk'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Pangnirtung', 'America/Pangnirtung'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port_of_Spain'), ('America/Porto_Acre', 'America/Porto_Acre'), ('America/Porto_Velho', 'America/Porto_Velho'), ('America/Puerto_Rico', 'America/Puerto_Rico'), ('America/Punta_Arenas', 'America/Punta_Arenas'), ('America/Rainy_River', 'America/Rainy_River'), ('America/Rankin_Inlet', 'America/Rankin_Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio_Branco'), ('America/Rosario', 'America/Rosario'), ('America/Santa_Isabel', 'America/Santa_Isabel'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo_Domingo'), ('America/Sao_Paulo', 'America/Sao_Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Shiprock', 'America/Shiprock'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St_Barthelemy'), ('America/St_Johns', 'America/St_Johns'), ('America/St_Kitts', 'America/St_Kitts'), ('America/St_Lucia', 'America/St_Lucia'), ('America/St_Thomas', 'America/St_Thomas'), ('America/St_Vincent', 'America/St_Vincent'), ('America/Swift_Current', 'America/Swift_Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Thunder_Bay', 'America/Thunder_Bay'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Virgin', 'America/Virgin'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('America/Yellowknife', 'America/Yellowknife'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/South_Pole', 'Antarctica/South_Pole'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Ashkhabad', 'Asia/Ashkhabad'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Calcutta', 'Asia/Calcutta'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Choibalsan', 'Asia/Choibalsan'), ('Asia/Chongqing', 'Asia/Chongqing'), ('Asia/Chungking', 'Asia/Chungking'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Dacca', 'Asia/Dacca'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Harbin', 'Asia/Harbin'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Ho_Chi_Minh', 'Asia/Ho_Chi_Minh'), ('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Istanbul', 'Asia/Istanbul'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kashgar', 'Asia/Kashgar'), ('Asia/Kathmandu', 'Asia/Kathmandu'), ('Asia/Katmandu', 'Asia/Katmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Kolkata', 'Asia/Kolkata'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macao', 'Asia/Macao'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Rangoon', 'Asia/Rangoon'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Saigon', 'Asia/Saigon'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Tel_Aviv', 'Asia/Tel_Aviv'), ('Asia/Thimbu', 'Asia/Thimbu'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ujung_Pandang', 'Asia/Ujung_Pandang'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Ulan_Bator', 'Asia/Ulan_Bator'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'), ('Atlantic/Faeroe', 'Atlantic/Faeroe'), ('Atlantic/Faroe', 'Atlantic/Faroe'), ('Atlantic/Jan_Mayen', 'Atlantic/Jan_Mayen'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South_Georgia'), ('Atlantic/St_Helena', 'Atlantic/St_Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/ACT', 'Australia/ACT'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken_Hill'), ('Australia/Canberra', 'Australia/Canberra'), ('Australia/Currie', 'Australia/Currie'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/LHI', 'Australia/LHI'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord_Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/NSW', 'Australia/NSW'), ('Australia/North', 'Australia/North'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Queensland', 'Australia/Queensland'), ('Australia/South', 'Australia/South'), ('Australia/Sydney', 'Australia/Sydney'), ('Australia/Tasmania', 'Australia/Tasmania'), ('Australia/Victoria', 'Australia/Victoria'), ('Australia/West', 'Australia/West'), ('Australia/Yancowinna', 'Australia/Yancowinna'), ('Brazil/Acre', 'Brazil/Acre'), ('Brazil/DeNoronha', 'Brazil/DeNoronha'), ('Brazil/East', 'Brazil/East'), ('Brazil/West', 'Brazil/West'), ('CET', 'CET'), ('CST6CDT', 'CST6CDT'), ('Canada/Atlantic', 'Canada/Atlantic'), ('Canada/Central', 'Canada/Central'), ('Canada/Eastern', 'Canada/Eastern'), ('Canada/Mountain', 'Canada/Mountain'), ('Canada/Newfoundland', 'Canada/Newfoundland'), ('Canada/Pacific', 'Canada/Pacific'), ('Canada/Saskatchewan', 'Canada/Saskatchewan'), ('Canada/Yukon', 'Canada/Yukon'), ('Chile/Continental', 'Chile/Continental'), ('Chile/EasterIsland', 'Chile/EasterIsland'), ('Cuba', 'Cuba'), ('EET', 'EET'), ('EST', 'EST'), ('EST5EDT', 'EST5EDT'), ('Egypt', 'Egypt'), ('Eire', 'Eire'), ('Etc/GMT', 'Etc/GMT'), ('Etc/GMT+0', 'Etc/GMT+0'), ('Etc/GMT+1', 'Etc/GMT+1'), ('Etc/GMT+10', 'Etc/GMT+10'), ('Etc/GMT+11', 'Etc/GMT+11'), ('Etc/GMT+12', 'Etc/GMT+12'), ('Etc/GMT+2', 'Etc/GMT+2'), ('Etc/GMT+3', 'Etc/GMT+3'), ('Etc/GMT+4', 'Etc/GMT+4'), ('Etc/GMT+5', 'Etc/GMT+5'), ('Etc/GMT+6', 'Etc/GMT+6'), ('Etc/GMT+7', 'Etc/GMT+7'), ('Etc/GMT+8', 'Etc/GMT+8'), ('Etc/GMT+9', 'Etc/GMT+9'), ('Etc/GMT-0', 'Etc/GMT-0'), ('Etc/GMT-1', 'Etc/GMT-1'), ('Etc/GMT-10', 'Etc/GMT-10'), ('Etc/GMT-11', 'Etc/GMT-11'), ('Etc/GMT-12', 'Etc/GMT-12'), ('Etc/GMT-13', 'Etc/GMT-13'), ('Etc/GMT-14', 'Etc/GMT-14'), ('Etc/GMT-2', 'Etc/GMT-2'), ('Etc/GMT-3', 'Etc/GMT-3'), ('Etc/GMT-4', 'Etc/GMT-4'), ('Etc/GMT-5', 'Etc/GMT-5'), ('Etc/GMT-6', 'Etc/GMT-6'), ('Etc/GMT-7', 'Etc/GMT-7'), ('Etc/GMT-8', 'Etc/GMT-8'), ('Etc/GMT-9', 'Etc/GMT-9'), ('Etc/GMT0', 'Etc/GMT0'), ('Etc/Greenwich', 'Etc/Greenwich'), ('Etc/UCT', 'Etc/UCT'), ('Etc/UTC', 'Etc/UTC'), ('Etc/Universal', 'Etc/Universal'), ('Etc/Zulu', 'Etc/Zulu'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belfast', 'Europe/Belfast'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle_of_Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Kyiv', 'Europe/Kyiv'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Nicosia', 'Europe/Nicosia'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San_Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Tiraspol', 'Europe/Tiraspol'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Uzhgorod', 'Europe/Uzhgorod'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zaporozhye', 'Europe/Zaporozhye'), ('Europe/Zurich', 'Europe/Zurich'), ('Factory', 'Factory'), ('GB', 'GB'), ('GB-Eire', 'GB-Eire'), ('GMT', 'GMT'), ('GMT+0', 'GMT+0'), ('GMT-0', 'GMT-0'), ('GMT0', 'GMT0'), ('Greenwich', 'Greenwich'), ('HST', 'HST'), ('Hongkong', 'Hongkong'), ('Iceland', 'Iceland'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Iran', 'Iran'), ('Israel', 'Israel'), ('Jamaica', 'Jamaica'), ('Japan', 'Japan'), ('Kwajalein', 'Kwajalein'), ('Libya', 'Libya'), ('MET', 'MET'), ('MST', 'MST'), ('MST7MDT', 'MST7MDT'), ('Mexico/BajaNorte', 'Mexico/BajaNorte'), ('Mexico/BajaSur', 'Mexico/BajaSur'), ('Mexico/General', 'Mexico/General'), ('NZ', 'NZ'), ('NZ-CHAT', 'NZ-CHAT'), ('Navajo', 'Navajo'), ('PRC', 'PRC'), ('PST8PDT', 'PST8PDT'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Chuuk', 'Pacific/Chuuk'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Enderbury', 'Pacific/Enderbury'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Johnston', 'Pacific/Johnston'), ('Pacific/Kanton', 'Pacific/Kanton'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago_Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Pohnpei', 'Pacific/Pohnpei'), ('Pacific/Ponape', 'Pacific/Ponape'), ('Pacific/Port_Moresby', 'Pacific/Port_Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Samoa', 'Pacific/Samoa'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Truk', 'Pacific/Truk'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis'), ('Pacific/Yap', 'Pacific/Yap'), ('Poland', 'Poland'), ('Portugal', 'Portugal'), ('ROC', 'ROC'), ('ROK', 'ROK'), ('Singapore', 'Singapore'), ('Turkey', 'Turkey'), ('UCT', 'UCT'), ('US/Alaska', 'US/Alaska'), ('US/Aleutian', 'US/Aleutian'), ('US/Arizona', 'US/Arizona'), ('US/Central', 'US/Central'), ('US/East-Indiana', 'US/East-Indiana'), ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), ('US/Indiana-Starke', 'US/Indiana-Starke'), ('US/Michigan', 'US/Michigan'), ('US/Mountain', 'US/Mountain'), ('US/Pacific', 'US/Pacific'), ('US/Samoa', 'US/Samoa'), ('UTC', 'UTC'), ('Universal', 'Universal'), ('W-SU', 'W-SU'), ('WET', 'WET'), ('Zulu', 'Zulu')], default='CET', max_length=50, null=True), + ), + ] From 3891a34242f9c96db92cee9685b8ca1e44e2ef90 Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 9 Jul 2025 17:34:20 +0200 Subject: [PATCH 6/7] add logging --- bizdev/admin.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bizdev/admin.py b/bizdev/admin.py index 79d6735..c92e99c 100644 --- a/bizdev/admin.py +++ b/bizdev/admin.py @@ -9,6 +9,7 @@ from django.utils.html import format_html import csv import io import time +import logging from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate from .forms import FileImportForm, EmailTemplateSelectionForm @@ -20,6 +21,8 @@ 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') @@ -324,6 +327,8 @@ class ProspectAdmin(SyncedObjectAdmin): 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(): @@ -349,6 +354,8 @@ class ProspectAdmin(SyncedObjectAdmin): 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) From e8768c4980ac4b344bbb5d66b32e0635a8d89c18 Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 9 Jul 2025 17:39:59 +0200 Subject: [PATCH 7/7] adds import for send_mail --- bizdev/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bizdev/admin.py b/bizdev/admin.py index c92e99c..916b63f 100644 --- a/bizdev/admin.py +++ b/bizdev/admin.py @@ -5,6 +5,7 @@ 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