apikeys
Laurent 4 months ago
parent 09620693e8
commit ff0fb01246
  1. 9
      api/serializers.py
  2. 4
      api/urls.py
  3. 17
      api/views.py
  4. 531
      crm/admin.py
  5. 70
      crm/admin_urls.py
  6. 57
      crm/filters.py
  7. 97
      crm/forms.py
  8. 114
      crm/migrations/0001_initial.py
  9. 60
      crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py
  10. 32
      crm/migrations/0003_remove_prospect_region_prospect_address_and_more.py
  11. 32
      crm/migrations/0004_remove_prospect_name_prospect_entity_name_and_more.py
  12. 18
      crm/migrations/0005_prospect_phone.py
  13. 198
      crm/models.py
  14. 17
      crm/serializers.py
  15. 25
      crm/templates/admin/crm/app_index.html
  16. 81
      crm/templates/admin/crm/email_users.html
  17. 22
      crm/templates/admin/crm/prospect/change_list.html
  18. 53
      crm/templates/admin/crm/prospect/import_file.html
  19. 29
      crm/templates/admin/crm/select_email_template.html
  20. 506
      crm/views.py
  21. 2
      padelclub_backend/settings.py
  22. 7
      padelclub_backend/urls.py
  23. 11
      sample_prospects.csv
  24. 18
      tournaments/migrations/0131_alter_playerregistration_contact_name.py
  25. 20
      tournaments/migrations/0132_alter_purchase_user.py
  26. 2
      tournaments/models/purchase.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):

@ -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)),

@ -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

@ -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')
search_fields = ('description',)
filter_horizontal = ('prospects',)
date_hierarchy = '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 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'
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'<a href="{url}">{event.html_desc()}</a>')
return format_html('<br>'.join(event_links))
return "No events"
related_events.short_description = "Related Events"
def changelist_view(self, request, extra_context=None):
# Add the URL with a filter for the current user
user_filter_url = "{}?related_user__id__exact={}".format(
reverse('admin:crm_prospect_changelist'),
request.user.id
)
list_filter = ('sent', 'opened', 'clicked')
search_fields = (
'prospect__name',
'prospect__email',
'campaign__subject'
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',
}
)
readonly_fields = (
'tracking_id', 'sent', 'sent_at',
'opened', 'opened_at',
'clicked', 'clicked_at'
if entity_name:
entity, entity_created = Entity.objects.get_or_create(
name=entity_name,
defaults={'name': entity_name}
)
date_hierarchy = 'sent_at'
def sent_status(self, obj):
return self._get_status_html(obj.sent, obj.sent_at)
sent_status.short_description = 'Sent'
sent_status.allow_tags = True
def opened_status(self, obj):
return self._get_status_html(obj.opened, obj.opened_at)
opened_status.short_description = 'Opened'
opened_status.allow_tags = True
def clicked_status(self, obj):
return self._get_status_html(obj.clicked, obj.clicked_at)
clicked_status.short_description = 'Clicked'
clicked_status.allow_tags = True
def _get_status_html(self, status, date):
if status:
return format_html(
'<span style="color: green;">✓</span> {}',
date.strftime('%Y-%m-%d %H:%M') if date else ''
prospect.entities.add(entity)
if is_customer:
activity = Activity.objects.create(
status=Status.CUSTOMER,
)
return format_html('<span style="color: red;">✗</span>')
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 = 'creation_date'
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
# Pre-populate fields from URL parameters
if 'prospect' in request.GET:
try:
prospect_id = request.GET['prospect']
prospect = Prospect.objects.get(id=prospect_id)
form.base_fields['prospects'].initial = prospect
form.base_fields['related_user'].initial = request.user
# You can set other fields based on the prospect
# form.base_fields['title'].initial = f"Event for {prospect.}"
# form.base_fields['status'].initial = 'pending'
except (Prospect.DoesNotExist, ValueError):
pass
return form
def get_event_display(self, obj):
return str(obj)
get_event_display.short_description = 'Activity'
# @admin.register(EmailCampaign)
# class EmailCampaignAdmin(admin.ModelAdmin):
# list_display = ('subject', 'event', 'sent_at')
# list_filter = ('sent_at',)
# search_fields = ('subject', 'content')
# date_hierarchy = 'sent_at'
# readonly_fields = ('sent_at',)
# @admin.register(EmailTracker)
# class EmailTrackerAdmin(admin.ModelAdmin):
# list_display = (
# 'campaign',
# 'prospect',
# 'tracking_id',
# 'sent_status',
# 'opened_status',
# 'clicked_status'
# )
# list_filter = ('sent', 'opened', 'clicked')
# search_fields = (
# 'prospect__name',
# 'prospect__email',
# 'campaign__subject'
# )
# readonly_fields = (
# 'tracking_id', 'sent', 'sent_at',
# 'opened', 'opened_at',
# 'clicked', 'clicked_at'
# )
# date_hierarchy = 'sent_at'
# def sent_status(self, obj):
# return self._get_status_html(obj.sent, obj.sent_at)
# sent_status.short_description = 'Sent'
# sent_status.allow_tags = True
# def opened_status(self, obj):
# return self._get_status_html(obj.opened, obj.opened_at)
# opened_status.short_description = 'Opened'
# opened_status.allow_tags = True
# def clicked_status(self, obj):
# return self._get_status_html(obj.clicked, obj.clicked_at)
# clicked_status.short_description = 'Clicked'
# clicked_status.allow_tags = True
# def _get_status_html(self, status, date):
# if status:
# return format_html(
# '<span style="color: green;">✓</span> {}',
# date.strftime('%Y-%m-%d %H:%M') if date else ''
# )
# return format_html('<span style="color: red;">✗</span>')

@ -0,0 +1,70 @@
from django.urls import path
from django.http import HttpResponse
from tournaments.models import CustomUser
from tournaments.models.enums import UserOrigin
from django.core.mail import send_mail
import time
def users_list(with_tournaments):
return CustomUser.objects.filter(origin=UserOrigin.APP).exclude(purchase__isnull=False).filter(events__isnull=with_tournaments)
def email_users_with_tournaments_count(request):
users = users_list(False)
emails = [user.email for user in users]
return HttpResponse(f'users = {len(users)}, \n\nemails = {emails}')
def email_users_count(request):
users = users_list(True)
emails = [user.email for user in users]
return HttpResponse(f'users = {len(users)}, \n\nemails = {emails}')
def email_users_view(request):
return email_users(request, users_list(True), 0)
def email_users_with_tournaments(request):
return email_users(request, users_list(False), 1)
def email_users(request, users, template_index):
users = users_list(True)
subject = 'check Padel Club'
from_email = 'laurent@padelclub.app'
sent_count = 0
error_emails = []
all_emails = []
for user in users:
mail_body = template(user, template_index) # f'Bonjour {user.first_name}, cool la vie ?'
all_emails.append(user.email)
try:
send_mail(
subject,
mail_body,
from_email,
[user.email],
fail_silently=False,
)
sent_count += 1
except Exception as e:
error_emails.append(user.email)
time.sleep(1)
return HttpResponse(f'users = {len(users)}, sent = {sent_count}, errors = {len(error_emails)}, \n\nemails = {all_emails}, \n\nerror emails = {error_emails}')
def template(user, index):
if index == 0:
return f'Bonjour {user.first_name}, \n\n'
else:
return f'Bonjour {user.first_name}, \n\nJe te remercie d\'avoir téléchargé Padel Club. J\'ai pu voir que tu avais créé quelques tournois mais sans aller plus loin, est-ce que tu pourrais me dire ce qui t\'as freiné ?\n\nLaurent Morvillier'
urlpatterns = [
path('email_users/', email_users_view, name='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'),
]

@ -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
)

@ -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'})
)

@ -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'),
),
]

@ -1,60 +0,0 @@
# Generated by Django 4.2.11 on 2024-12-08 20:58
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('crm', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='event',
options={'ordering': ['-created_at'], 'permissions': [('manage_events', 'Can manage events'), ('view_events', 'Can view events')]},
),
migrations.AlterModelOptions(
name='prospect',
options={'permissions': [('manage_prospects', 'Can manage prospects'), ('view_prospects', 'Can view prospects')]},
),
migrations.AddField(
model_name='event',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_events', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='event',
name='modified_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='event',
name='modified_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_events', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='prospect',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_prospects', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='prospect',
name='modified_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='prospect',
name='modified_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_prospects', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='event',
name='date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

@ -1,32 +0,0 @@
# Generated by Django 5.1 on 2024-12-16 15:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('crm', '0002_alter_event_options_alter_prospect_options_and_more'),
]
operations = [
migrations.RemoveField(
model_name='prospect',
name='region',
),
migrations.AddField(
model_name='prospect',
name='address',
field=models.CharField(blank=True, max_length=200, null=True),
),
migrations.AddField(
model_name='prospect',
name='city',
field=models.CharField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name='prospect',
name='zip_code',
field=models.CharField(blank=True, max_length=20, null=True),
),
]

@ -1,32 +0,0 @@
# Generated by Django 5.1 on 2024-12-16 16:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('crm', '0003_remove_prospect_region_prospect_address_and_more'),
]
operations = [
migrations.RemoveField(
model_name='prospect',
name='name',
),
migrations.AddField(
model_name='prospect',
name='entity_name',
field=models.CharField(blank=True, max_length=200, null=True),
),
migrations.AddField(
model_name='prospect',
name='first_name',
field=models.CharField(blank=True, max_length=200, null=True),
),
migrations.AddField(
model_name='prospect',
name='last_name',
field=models.CharField(blank=True, max_length=200, null=True),
),
]

@ -1,18 +0,0 @@
# Generated by Django 5.1 on 2024-12-16 16:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('crm', '0004_remove_prospect_name_prospect_entity_name_and_more'),
]
operations = [
migrations.AddField(
model_name='prospect',
name='phone',
field=models.CharField(blank=True, max_length=25, null=True),
),
]

@ -1,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 = '<table><tr>'
for field in fields:
html += f'<td style="padding:0px 5px;">{field}</td>'
html += '</tr></table>'
return html
@receiver(m2m_changed, sender=Activity.prospects.through)
def update_prospect_last_update(sender, instance, action, pk_set, **kwargs):
instance.prospects.update(last_update=timezone.now())
class EmailTemplate(BaseModel):
name = models.CharField(max_length=100)
subject = models.CharField(max_length=200)
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']

@ -0,0 +1,17 @@
from rest_framework import serializers
from .models import Activity, Prospect, Entity
class CRMActivitySerializer(serializers.ModelSerializer):
class Meta:
model = Activity
fields = '__all__'
class CRMProspectSerializer(serializers.ModelSerializer):
class Meta:
model = Prospect
fields = '__all__'
class CRMEntitySerializer(serializers.ModelSerializer):
class Meta:
model = Entity
fields = '__all__'

@ -0,0 +1,25 @@
<!-- templates/admin/crm/app_index.html -->
{% extends "admin/app_index.html" %}
{% load i18n %}
{% block content %}
<div class="module" style="margin-bottom: 20px;">
<!-- <h2>{% trans "Actions" %}</h2> -->
<div class="form-row">
<a href="{% url 'crm_email_count' %}" class="button default" style="margin-right: 5px;">
{% trans "Count Users no event" %}
</a>
<a href="{% url 'crm_email_users' %}" class="button default" style="margin-right: 5px;">
{% trans "Insta send email no event" %}
</a>
<a href="{% url 'crm_email_with_tournaments_count' %}" class="button default" style="margin-right: 5px;">
{% trans "Count Users" %}
</a>
<a href="{% url 'email_users_with_tournaments' %}" class="button default" style="margin-right: 5px;">
{% trans "Insta send email" %}
</a>
</div>
</div>
{{ block.super }}
{% endblock %}

@ -0,0 +1,81 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static admin_list %}
{% block title %}Email Users{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; Email Users
</div>
{% endblock %}
{% block content %}
<div class="module filtered">
<h2>Filter Users for Email</h2>
<form method="post" action="{% url 'admin:email_users' %}">
{% csrf_token %}
<div class="form-row">
<div class="field-box">
<label for="user_origin">User Origin:</label>
<select name="user_origin" id="user_origin" class="vTextField">
<option value="">All Origins</option>
{% for choice in user_origin_choices %}
<option value="{{ choice.0 }}" {% if choice.0 == selected_origin %}selected{% endif %}>
{{ choice.1 }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row">
<div class="field-box">
<label for="has_purchase">
<input type="checkbox" name="has_purchase" id="has_purchase" value="1"
{% if has_purchase %}checked{% endif %}>
User has made a purchase
</label>
</div>
</div>
<div class="form-row">
<input type="submit" value="Filter Users" class="default" name="_filter">
</div>
</form>
{% if filtered_users %}
<div class="results">
<h3>Filtered Users ({{ filtered_users|length }} found)</h3>
<div class="module">
<table cellspacing="0">
<thead>
<tr>
<th>Email</th>
<th>Origin</th>
<th>Has Purchase</th>
<th>Date Joined</th>
</tr>
</thead>
<tbody>
{% for user in filtered_users %}
<tr class="{% cycle 'row1' 'row2' %}">
<td>{{ user.email }}</td>
<td>{{ user.get_origin_display }}</td>
<td>{{ user.has_purchase|yesno:"Yes,No" }}</td>
<td>{{ user.date_joined|date:"M d, Y" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="4">No users found matching criteria.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
{% endblock %}

@ -0,0 +1,22 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
{{ block.super }}
<li>
<a href="{% url 'admin:import_file' %}" class="addlink" style="margin-right: 5px;">Import</a>
<a href="{% url 'admin:import_app_users' %}" class="addlink" style="margin-right: 5px;">Import App Users</a>
<a href="{% url 'admin:cleanup' %}" class="addlink" style="margin-right: 5px;">Reset</a>
</li>
{% endblock %}
{% block search %}
{{ block.super }}
<div style="margin-top: 10px; margin-bottom: 10px;">
<a href="{{ user_filter_url }}" class="button">
My Prospects
</a>
</div>
{% endblock %}

@ -0,0 +1,53 @@
<!-- templates/admin/import_file.html -->
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block title %}{% trans 'Import File' %}{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; {% trans 'Import File' %}
</div>
{% endblock %}
{% block content %}
<div class="module">
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
<div class="form-row">
<div class="field-box">
{{ form.source.label_tag }}
{{ form.source }}
</div>
<div class="field-box">
{{ form.file.label_tag }}
{{ form.file }}
{% if form.file.help_text %}
<div class="help">{{ form.file.help_text }}</div>
{% endif %}
{% if form.file.errors %}
<ul class="errorlist">
{% for error in form.file.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<div class="submit-row">
<input type="submit" value="{% trans 'Import File' %}" class="default" />
<a href="{% url 'admin:index' %}" class="button cancel-link">{% trans 'Cancel' %}</a>
</div>
</form>
</div>
<div class="module">
<h2>{% trans 'Instructions' %}</h2>
<p>{% trans 'Select a file to import and click "Import File" to process it.' %}</p>
<p>{% trans 'Supported file formats: CSV, Excel (XLSX, XLS), and Text files.' %}</p>
</div>
{% endblock %}

@ -0,0 +1,29 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls %}
{% block content %}
<div id="content-main">
<form action="" method="post">
{% csrf_token %}
<h2>{{ title }}</h2>
<p>You have selected the following prospects:</p>
<ul>
{% for prospect in prospects %}
<li>{{ prospect.name }} ({{ prospect.email }})</li>
<input type="hidden" name="_selected_action" value="{{ prospect.pk }}" />
{% endfor %}
</ul>
<fieldset class="module aligned">
<h2>Select an email template:</h2>
{{ form.as_p }}
</fieldset>
<div class="submit-row">
<input type="hidden" name="action" value="send_email" />
<input type="submit" name="apply" value="Send Email" class="default" />
</div>
</form>
</div>
{% endblock %}

@ -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')
# form = ProspectForm(request.POST, instance=prospect)
# if form.is_valid():
# prospect = form.save(commit=False)
# if not pk: # New prospect
# prospect.created_by = request.user
# prospect.modified_by = request.user
# prospect.save()
# action = 'updated' if pk else 'added'
# messages.success(request,
# f'Prospect {prospect.entity_name} has been {action} successfully!')
# return redirect('crm:events')
# else:
# form = ProspectForm(instance=prospect)
# context = {
# 'form': form,
# 'is_edit': prospect is not None,
# 'first_title': prospect.entity_name if prospect else 'Add Prospect',
# 'second_title': prospect.full_name() if prospect else None
# }
# return render(request, 'crm/prospect_form.html', context)
# # @permission_required('crm.view_crm', raise_exception=True)
# # def add_prospect(request):
# # if request.method == 'POST':
# # entity_name = request.POST.get('entity_name')
# # first_name = request.POST.get('first_name')
# # last_name = request.POST.get('last_name')
# # email = request.POST.get('email')
# # phone = request.POST.get('phone')
# # address = request.POST.get('address')
# # zip_code = request.POST.get('zip_code')
# # city = request.POST.get('city')
# # # region = request.POST.get('region')
# # try:
# # prospect = Prospect.objects.create(
# # entity_name=entity_name,
# # first_name=first_name,
# # last_name=last_name,
# # email=email,
# # phone=phone,
# # address=address,
# # zip_code=zip_code,
# # city=city,
# # # region=region,
# # created_by=request.user,
# # modified_by=request.user
# # )
# # messages.success(request, f'Prospect {name} has been added successfully!')
# # return redirect('crm:events') # or wherever you want to redirect after success
# # except Exception as e:
# # messages.error(request, f'Error adding prospect: {str(e)}')
# # return render(request, 'crm/add_prospect.html')
# class EventCreateView(CRMAccessMixin, CreateView):
# model = Event
# form_class = EventForm
# template_name = 'crm/event_form.html'
# success_url = reverse_lazy('crm:planned_events')
# def get_initial(self):
# initial = super().get_initial()
# prospect_id = self.kwargs.get('prospect_id')
# if prospect_id:
# initial['prospects'] = [prospect_id]
# return initial
# def form_valid(self, form):
# form.instance.created_by = self.request.user
# form.instance.modified_by = self.request.user
# return super().form_valid(form)
# class EditEventView(CRMAccessMixin, UpdateView):
# model = Event
# form_class = EventForm
# template_name = 'crm/event_form.html'
# success_url = reverse_lazy('crm:planned_events')
# def form_valid(self, form):
# form.instance.modified_by = self.request.user
# response = super().form_valid(form)
# messages.success(self.request, 'Event updated successfully!')
# return response
# class StartEventView(CRMAccessMixin, BaseUpdateView):
# model = Event
# http_method_names = ['post', 'get']
# def get(self, request, *args, **kwargs):
# return self.post(request, *args, **kwargs)
# def post(self, request, *args, **kwargs):
# event = get_object_or_404(Event, pk=kwargs['pk'], status='PLANNED')
# event.status = 'ACTIVE'
# event.save()
# if event.type == 'MAIL':
# return HttpResponseRedirect(
# reverse_lazy('crm:setup_email_campaign', kwargs={'event_id': event.id})
# )
# elif event.type == 'SMS':
# return HttpResponseRedirect(
# reverse_lazy('crm:setup_sms_campaign', kwargs={'event_id': event.id})
# )
# elif event.type == 'PRESS':
# return HttpResponseRedirect(
# reverse_lazy('crm:setup_press_release', kwargs={'event_id': event.id})
# )
# messages.success(request, 'Event started successfully!')
# return HttpResponseRedirect(reverse_lazy('crm:planned_events'))
# class EventListView(CRMAccessMixin, ListView):
# model = Event
# template_name = 'crm/events.html'
# context_object_name = 'events' # We won't use this since we're providing custom context
# def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['planned_events'] = Event.objects.filter(
# status='PLANNED'
# ).order_by('date')
# context['completed_events'] = Event.objects.filter(
# status='COMPLETED'
# ).order_by('-date')
# return context
# class ProspectListView(CRMAccessMixin, ListView):
# model = Prospect
# template_name = 'crm/prospect_list.html'
# context_object_name = 'prospects'
# filterset_class = ProspectFilter
# def get_queryset(self):
# return super().get_queryset().prefetch_related('prospectstatus_set__status')
# def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['filter'] = self.filterset_class(
# self.request.GET,
# queryset=self.get_queryset()
# )
# return context
# class CSVImportView(CRMAccessMixin, FormView):
# template_name = 'crm/csv_import.html'
# form_class = CSVImportForm
# success_url = reverse_lazy('prospect-list')
# def form_valid(self, form):
# csv_file = TextIOWrapper(
# form.cleaned_data['csv_file'].file,
# encoding='utf-8-sig' # Handle potential BOM in CSV
# )
# reader = csv.reader(csv_file, delimiter=';') # Using semicolon delimiter
# # Skip header if exists
# next(reader, None)
# created_count = 0
# updated_count = 0
# error_count = 0
# for row in reader:
# try:
# 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
# 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,
# }
# )
# messages.success(request, f'Prospect {name} has been added successfully!')
# return redirect('crm:events') # or wherever you want to redirect after success
# if created:
# prospect.created_by = self.request.user
# prospect.save()
# created_count += 1
# else:
# updated_count += 1
# 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)
# 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)

@ -36,7 +36,7 @@ INSTALLED_APPS = [
'sync',
'tournaments',
'shop',
# 'crm',
'crm',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',

@ -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)

@ -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
unable to load file from base commit

@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2025-07-03 10:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0130_playerregistration_contact_email_and_more'),
]
operations = [
migrations.AlterField(
model_name='playerregistration',
name='contact_name',
field=models.CharField(blank=True, max_length=200, null=True),
),
]

@ -0,0 +1,20 @@
# Generated by Django 5.1 on 2025-07-09 12:55
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0131_alter_playerregistration_contact_name'),
]
operations = [
migrations.AlterField(
model_name='purchase',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchases', to=settings.AUTH_USER_MODEL),
),
]

@ -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)

Loading…
Cancel
Save