Compare commits

..

2 Commits

Author SHA1 Message Date
Razmig Sarkissian dba062d2d1 wip 8 months ago
Raz 3205a1400e wip 8 months ago
  1. 4
      .gitignore
  2. 10
      CLAUDE.md
  3. 22
      api/admin.py
  4. 7
      api/apps.py
  5. 24
      api/authentication.py
  6. 36
      api/migrations/0001_initial.py
  7. 23
      api/models.py
  8. 232
      api/serializers.py
  9. 35
      api/urls.py
  10. 1283
      api/utils.py
  11. 802
      api/views.py
  12. 1
      asgi.py
  13. 15
      authentication/admin.py
  14. 6
      authentication/apps.py
  15. 36
      authentication/migrations/0001_initial.py
  16. 18
      authentication/migrations/0002_rename_model_name_device_device_model.py
  17. 0
      authentication/migrations/__init__.py
  18. 2
      authentication/models/__init__.py
  19. 12
      authentication/models/device.py
  20. 13
      authentication/models/login_log.py
  21. 29
      authentication/serializers.py
  22. 5
      authentication/utils.py
  23. 116
      authentication/views.py
  24. 0
      biz/__init__.py
  25. 534
      biz/admin.py
  26. 70
      biz/admin_urls.py
  27. 163
      biz/filters.py
  28. 61
      biz/forms.py
  29. 103
      biz/migrations/0001_initial.py
  30. 18
      biz/migrations/0002_alter_prospect_email.py
  31. 18
      biz/migrations/0003_alter_activity_status.py
  32. 18
      biz/migrations/0004_prospect_contact_again.py
  33. 38
      biz/migrations/0005_alter_activity_status_campaign.py
  34. 19
      biz/migrations/0006_alter_campaign_id.py
  35. 37
      biz/migrations/0007_prospectgroup_delete_campaign.py
  36. 23
      biz/migrations/0008_alter_activity_declination_reason_and_more.py
  37. 0
      biz/migrations/__init__.py
  38. 221
      biz/models.py
  39. 448
      biz/templates/admin/biz/dashboard.html
  40. 81
      biz/templates/admin/biz/email_users.html
  41. 11
      biz/templates/admin/biz/prospect/change_list.html
  42. 53
      biz/templates/admin/biz/prospect/import_file.html
  43. 29
      biz/templates/admin/biz/select_email_template.html
  44. 58
      biz/templates/biz/add_prospect.html
  45. 34
      biz/templates/biz/csv_import.html
  46. 17
      biz/templates/biz/prospect_form.html
  47. 81
      biz/templates/biz/prospect_list.html
  48. 0
      biz/templatetags/__init__.py
  49. 7
      biz/templatetags/crm_tags.py
  50. 3
      biz/tests.py
  51. 284
      biz/views.py
  52. 0
      crm/__init__.py
  53. 1
      crm/_instructions/base.md
  54. 96
      crm/admin.py
  55. 4
      crm/apps.py
  56. 18
      crm/filters.py
  57. 40
      crm/forms.py
  58. 94
      crm/migrations/0001_initial.py
  59. 60
      crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py
  60. 0
      crm/migrations/__init__.py
  61. 4
      crm/mixins.py
  62. 112
      crm/models.py
  63. 0
      crm/services.py
  64. 10
      crm/static/js/prospect_list.js
  65. 31
      crm/templates/crm/add_prospect.html
  66. 4
      crm/templates/crm/base.html
  67. 6
      crm/templates/crm/event_form.html
  68. 4
      crm/templates/crm/event_row.html
  69. 29
      crm/templates/crm/events.html
  70. 57
      crm/templates/crm/prospect_list.html
  71. 4
      crm/templates/crm/send_bulk_email.html
  72. 0
      crm/templatetags/__init__.py
  73. 7
      crm/templatetags/crm_tags.py
  74. 0
      crm/tests.py
  75. 12
      crm/urls.py
  76. 186
      crm/views.py
  77. 26
      padelclub_backend/asgi.py
  78. 1
      padelclub_backend/routing.py
  79. 103
      padelclub_backend/settings.py
  80. 69
      padelclub_backend/settings_app.py
  81. 49
      padelclub_backend/settings_local.py.dist
  82. 32
      padelclub_backend/urls.py
  83. 12
      requirements.txt
  84. 11
      sample_prospects.csv
  85. 0
      shop/__init__.py
  86. 414
      shop/admin.py
  87. 8
      shop/apps.py
  88. 94
      shop/cart.py
  89. 10
      shop/context_processors.py
  90. 22
      shop/forms.py
  91. 0
      shop/management/commands/__init__.py
  92. 206
      shop/management/commands/create_initial_shop_data.py
  93. 53
      shop/migrations/0001_initial.py
  94. 39
      shop/migrations/0002_rename_color_productcolor_rename_size_productsize_and_more.py
  95. 39
      shop/migrations/0003_rename_productcolor_color_rename_productsize_size_and_more.py
  96. 24
      shop/migrations/0004_cartitem_color_cartitem_size.py
  97. 23
      shop/migrations/0005_alter_color_name_alter_size_name.py
  98. 22
      shop/migrations/0006_alter_product_options_product_order.py
  99. 18
      shop/migrations/0007_product_cut.py
  100. 22
      shop/migrations/0008_alter_product_options_and_more.py
  101. Some files were not shown because too many files have changed in this diff Show More

4
.gitignore vendored

@ -7,7 +7,6 @@ padelclub_backend/settings_local.py
myenv/
shared/config_local.py
logs/
# Byte-compiled / optimized / DLL files
__pycache__/
@ -174,6 +173,3 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Media files
media/
*/media/

@ -1,10 +0,0 @@
This is a django project that is used for padel tournaments management.
Here are the different apps:
- api: the api is used to communicate with the mobile app
- authentication: regroups authentications services
- biz: it's our CRM project to manage customers
- shop: the website that hosts the shop
- sync: the project used to synchronize the data between apps and the backend
- tournaments: the main website the display everything about the padel tournaments
In production, the project runs with ASGI because we use websockets in the sync app.

@ -1,22 +0,0 @@
from django.contrib import admin
from rest_framework_api_key.admin import APIKeyModelAdmin
from rest_framework_api_key.models import APIKey as DefaultAPIKey
from .models import APIKey
# Unregister the default APIKey admin
admin.site.unregister(DefaultAPIKey)
@admin.register(APIKey)
class APIKeyAdmin(APIKeyModelAdmin):
list_display = [*APIKeyModelAdmin.list_display, "user"]
list_filter = [*APIKeyModelAdmin.list_filter, "user"]
search_fields = [*APIKeyModelAdmin.search_fields, "user__username", "user__email"]
raw_id_fields = ['user']
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
# Make user field required
if 'user' in form.base_fields:
form.base_fields['user'].required = True
return form

@ -1,7 +0,0 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'
verbose_name = 'API'

@ -1,24 +0,0 @@
from rest_framework_api_key.permissions import BaseHasAPIKey
from .models import APIKey
class HasAPIKey(BaseHasAPIKey):
model = APIKey
def has_permission(self, request, view):
# First check if we have a valid API key
has_api_key = super().has_permission(request, view)
if has_api_key:
# Get the API key from the request
key = self.get_key(request)
if key:
try:
api_key = APIKey.objects.get_from_key(key)
# Set the request.user to the user associated with the API key
request.user = api_key.user
return True
except APIKey.DoesNotExist:
pass
return False

@ -1,36 +0,0 @@
# Generated by Django 5.1 on 2025-09-17 07:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='APIKey',
fields=[
('id', models.CharField(editable=False, max_length=150, primary_key=True, serialize=False, unique=True)),
('prefix', models.CharField(editable=False, max_length=8, unique=True)),
('hashed_key', models.CharField(editable=False, max_length=150)),
('created', models.DateTimeField(auto_now_add=True, db_index=True)),
('name', models.CharField(default=None, help_text='A free-form name for the API key. Need not be unique. 50 characters max.', max_length=50)),
('revoked', models.BooleanField(blank=True, default=False, help_text='If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)')),
('expiry_date', models.DateTimeField(blank=True, help_text='Once API key expires, clients cannot use it anymore.', null=True, verbose_name='Expires')),
('user', models.ForeignKey(help_text='The user this API key belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'API Key',
'verbose_name_plural': 'API Keys',
'ordering': ('-created',),
'abstract': False,
},
),
]

@ -1,23 +0,0 @@
from django.db import models
from rest_framework_api_key.models import AbstractAPIKey
from tournaments.models import CustomUser
class APIKey(AbstractAPIKey):
"""
API Key model linked to a specific user.
This allows filtering API access based on the user associated with the API key.
"""
user = models.ForeignKey(
CustomUser,
on_delete=models.CASCADE,
related_name='api_keys',
help_text='The user this API key belongs to'
)
class Meta(AbstractAPIKey.Meta):
verbose_name = "API Key"
verbose_name_plural = "API Keys"
def __str__(self):
return f"API Key for {self.user.username}"

@ -1,18 +1,23 @@
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
from django.contrib.auth import password_validation
from django.utils.translation import gettext_lazy as _
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.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes
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 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
from biz.models import Activity, Prospect, Entity
from shared.cryptography import encryption_util
from tournaments.models.draw_log import DrawLog
from tournaments.models.enums import UserOrigin
class EncryptedUserField(serializers.Field):
def to_representation(self, value):
@ -47,13 +52,11 @@ class UserSerializer(serializers.ModelSerializer):
if 'country' in validated_data:
country = validated_data['country']
username_lower = validated_data['username'].lower()
if CustomUser.objects.filter(username__iexact=username_lower) | CustomUser.objects.filter(email__iexact=username_lower):
raise serializers.ValidationError("Cet identifiant est déjà utilisé. Veuillez en choisir un autre :)")
if CustomUser.objects.filter(username__iexact=validated_data['username'].lower()):
raise IntegrityError("Le nom d'utilisateur existe déjà")
user = CustomUser.objects.create_user(
username=validated_data['username'],
last_update=validated_data.get('last_update'),
email=validated_data['email'],
password=validated_data['password'],
first_name=validated_data['first_name'],
@ -75,17 +78,10 @@ class UserSerializer(serializers.ModelSerializer):
loser_bracket_match_format_preference=validated_data.get('loser_bracket_match_format_preference'),
loser_bracket_mode=validated_data.get('loser_bracket_mode'),
origin=UserOrigin.APP,
user_role=None,
registration_payment_mode=validated_data.get('registration_payment_mode', RegistrationPaymentMode.DISABLED),
umpire_custom_mail=validated_data.get('umpire_custom_mail'),
umpire_custom_contact=validated_data.get('umpire_custom_contact'),
umpire_custom_phone=validated_data.get('umpire_custom_phone'),
hide_umpire_mail=validated_data.get('hide_umpire_mail', False),
hide_umpire_phone=validated_data.get('hide_umpire_phone', True),
disable_ranking_federal_ruling=validated_data.get('disable_ranking_federal_ruling', False)
)
self.send_email(self.context['request'], user)
if not settings.DEBUG:
self.send_email(self.context['request'], user)
# RegistrationProfile.objects.filter(user=user).send_activation_email()
return user
@ -109,162 +105,79 @@ class UserSerializer(serializers.ModelSerializer):
model = CustomUser
fields = '__all__' # ['id', 'username', 'password', 'umpire_code', 'clubs', 'phone', 'first_name', 'last_name', 'licence_id']
class CustomUserSerializer(serializers.ModelSerializer): ### the one matching the CustomUser class and used for sync
class UserUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
fields = CustomUser.fields_for_update()
class ShortUserSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
fields = ['id', 'first_name', 'last_name']
class ClubSerializer(serializers.ModelSerializer):
class Meta:
model = Club
fields = '__all__'
def create(self, validated_data):
user = self.context['request'].user
validated_data['creator'] = user
return super().create(validated_data)
fields = '__all__' # ['id', 'name', 'acronym', 'phone', 'code', 'federal_club_data', 'address', 'city', 'zip_code', 'latitude', 'longitude']
class TournamentSerializer(serializers.ModelSerializer):
class Meta:
model = Tournament
fields = '__all__'
class TournamentSummarySerializer(serializers.ModelSerializer):
# English field names for all the information described in the comment
tournament_name = serializers.SerializerMethodField()
tournament_information = serializers.CharField(source='information', read_only=True)
start_date = serializers.SerializerMethodField()
end_date = serializers.SerializerMethodField()
tournament_category = serializers.SerializerMethodField() # P25/P100/P250 as string
tournament_type = serializers.SerializerMethodField() # homme/femme/mixte as string
tournament_age_category = serializers.SerializerMethodField() # U10, U12, Senior, +45, etc. as string
max_teams = serializers.IntegerField(source='team_count', read_only=True)
registered_teams_count = serializers.SerializerMethodField()
tournament_status = serializers.SerializerMethodField() # status as string
registration_link = serializers.SerializerMethodField()
umpire_name = serializers.SerializerMethodField()
umpire_phone = serializers.SerializerMethodField()
umpire_email = serializers.SerializerMethodField()
class Meta:
model = Tournament
fields = [
'id',
'tournament_name',
'tournament_information',
'start_date',
'end_date',
'tournament_category',
'tournament_type',
'tournament_age_category',
'max_teams',
'registered_teams_count',
'tournament_status',
'registration_link',
'umpire_name',
'umpire_phone',
'umpire_email'
]
def get_start_date(self, obj):
"""Get formatted start date"""
return obj.local_start_date()
def get_end_date(self, obj):
"""Get formatted end date"""
return obj.local_end_date()
def get_tournament_name(self, obj):
"""Get the tournament name"""
return obj.name or obj.name_and_event()
def get_tournament_category(self, obj):
"""Get tournament category as string label (P25, P100, P250, etc.)"""
return obj.level()
def get_tournament_type(self, obj):
"""Get tournament type as string label (homme, femme, mixte)"""
return obj.category()
def get_tournament_age_category(self, obj):
"""Get tournament age category as string label (U10, U12, Senior, +45, etc.)"""
return obj.age()
def get_registered_teams_count(self, obj):
"""Get number of registered teams"""
return len(obj.teams(False))
def get_tournament_status(self, obj):
"""Get tournament status as string"""
return obj.get_tournament_status()
def get_registration_link(self, obj):
"""Get appropriate link based on tournament status"""
# This will need to be adapted based on your URL structure
# For now, returning a placeholder that you can customize
status = obj.get_online_registration_status()
base_url = "https://padelclub.app/"
if status.value in [1, 3, 5]: # OPEN, NOT_STARTED, WAITING_LIST_POSSIBLE
return f"{base_url}tournament/{obj.id}/info/"
elif status.value == 7: # IN_PROGRESS
return f"{base_url}tournament/{obj.id}/live/"
elif status.value == 8: # ENDED_WITH_RESULTS
return f"{base_url}tournament/{obj.id}/rankings/"
else:
return f"{base_url}tournament/{obj.id}/info/"
def get_umpire_name(self, obj):
"""Get umpire/referee name"""
return obj.umpire_contact()
def get_umpire_phone(self, obj):
"""Get umpire phone number"""
return obj.umpire_phone()
def get_umpire_email(self, obj):
"""Get umpire email address"""
return obj.umpire_mail()
# ['id', 'name', 'event', 'creator_id', 'start_date', 'end_date', 'creation_date',
# 'is_private', 'format', 'group_stage_format', 'round_format', 'loser_round_format', 'bracket_sort_mode',
# 'group_stage_count', 'rank_source_date', 'day_duration', 'team_count', 'team_sorting',
# 'federal_category', 'federal_level_category', 'federal_age_category', 'group_stage_court_count',
# 'seed_count', 'closed_registration_date', 'group_stage_additional_qualified', 'court_count', 'prioritize_club_members',
# 'qualified_per_group_stage', 'teams_per_group_stage']
class EventSerializer(serializers.ModelSerializer):
class Meta:
#club_id = serializers.PrimaryKeyRelatedField(queryset=Club.objects.all())
model = Event
fields = '__all__'
# ['id', 'club_id', 'date', 'name', 'federal_tournament_data', 'court_count', 'tenup_id',
# 'group_stage_format', 'round_format', 'loser_round_format']
class RoundSerializer(serializers.ModelSerializer):
class Meta:
# tournament_id = serializers.PrimaryKeyRelatedField(queryset=Tournament.objects.all())
# loser_id = serializers.PrimaryKeyRelatedField(queryset=Round.objects.all())
model = Round
fields = '__all__'
fields = '__all__' #['id', 'index', 'tournament_id', 'loser', 'format']
class GroupStageSerializer(serializers.ModelSerializer):
class Meta:
model = GroupStage
fields = '__all__'
fields = '__all__' # ['id', 'index', 'tournament_id', 'format']
class MatchSerializer(serializers.ModelSerializer):
class Meta:
# round_id = serializers.PrimaryKeyRelatedField(queryset=Round.objects.all())
# group_stage_id = serializers.PrimaryKeyRelatedField(queryset=GroupStage.objects.all())
model = Match
fields = '__all__'
# ['id', 'round_id', 'group_stage_id', 'index', 'format', 'court', 'start_date', 'end_date',
# 'serving_team_id', 'winning_team_id', 'losing_team_id']
class TeamScoreSerializer(serializers.ModelSerializer):
class Meta:
# match_id = serializers.PrimaryKeyRelatedField(queryset=Match.objects.all())
# player_registrations_ids = serializers.Man
model = TeamScore
fields = '__all__'
fields = '__all__' # ['id', 'match_id', 'score', 'walk_out', 'lucky_loser', 'player_registrations']
class TeamRegistrationSerializer(serializers.ModelSerializer):
class Meta:
# match_id = serializers.PrimaryKeyRelatedField(queryset=Match.objects.all())
# group_stage_id = serializers.PrimaryKeyRelatedField(queryset=GroupStage.objects.all())
model = TeamRegistration
fields = '__all__'
# ['id', 'group_stage_id', 'registration_date', 'call_date', 'bracket_position',
# 'group_stage_position', 'logo']
class PlayerRegistrationSerializer(serializers.ModelSerializer):
class Meta:
# team_registration_id = serializers.PrimaryKeyRelatedField(queryset=TeamRegistration.objects.all())
# team_state_id = serializers.PrimaryKeyRelatedField(queryset=TeamState.objects.all())
model = PlayerRegistration
fields = '__all__'
# ['id', 'team_registration_id', 'first_name', 'last_name', 'licence_id', 'rank', 'has_paid']
class PurchaseSerializer(serializers.ModelSerializer):
user = EncryptedUserField()
@ -273,11 +186,31 @@ class PurchaseSerializer(serializers.ModelSerializer):
model = Purchase
fields = '__all__'
def create(self, validated_data):
user = self.context['request'].user
validated_data['user'] = user
return super().create(validated_data)
class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(max_length=128, write_only=True, required=True)
new_password1 = serializers.CharField(max_length=128, write_only=True, required=True)
new_password2 = serializers.CharField(max_length=128, write_only=True, required=True)
def validate_old_password(self, value):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError(
_('Your old password was entered incorrectly. Please enter it again.')
)
return value
def validate(self, data):
if data['new_password1'] != data['new_password2']:
raise serializers.ValidationError({'new_password2': _("The two password fields didn't match.")})
password_validation.validate_password(data['new_password1'], self.context['request'].user)
return data
def save(self, **kwargs):
password = self.validated_data['new_password1']
user = self.context['request'].user
user.set_password(password)
user.save()
return user
class LiveMatchSerializer(serializers.ModelSerializer):
class Meta:
@ -331,34 +264,3 @@ class UnregisteredPlayerSerializer(serializers.ModelSerializer):
model = UnregisteredPlayer
fields = '__all__'
# ['id', 'team_registration_id', 'first_name', 'last_name', 'licence_id', 'rank', 'has_paid']
class ImageSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
def get_image_url(self, obj):
if obj.image:
return self.context['request'].build_absolute_uri(obj.image.url)
return None
class Meta:
model = Image
fields = ['id', 'title', 'description', 'image', 'image_url', 'uploaded_at',
'event', 'image_type']
read_only_fields = ['id', 'uploaded_at', 'image_url']
### CRM
class ActivitySerializer(serializers.ModelSerializer):
class Meta:
model = Activity
fields = '__all__'
class ProspectSerializer(serializers.ModelSerializer):
class Meta:
model = Prospect
fields = '__all__'
class EntitySerializer(serializers.ModelSerializer):
class Meta:
model = Entity
fields = '__all__'

@ -3,16 +3,11 @@ from rest_framework import routers
from rest_framework.authtoken.views import obtain_auth_token
from . import views
from sync.views import SynchronizationApi, UserDataAccessApi, DataAccessViewSet
from authentication.views import CustomAuthToken, Logout, ChangePasswordView
router = routers.DefaultRouter()
router.register(r'users', views.UserViewSet)
router.register(r'user-supervisors', views.SupervisorViewSet)
router.register(r'clubs', views.ClubViewSet)
router.register(r'tournaments', views.TournamentViewSet)
router.register(r'tournament-summaries', views.TournamentSummaryViewSet)
router.register(r'images', views.ImageViewSet)
router.register(r'events', views.EventViewSet)
router.register(r'rounds', views.RoundViewSet)
router.register(r'group-stages', views.GroupStageViewSet)
@ -27,42 +22,20 @@ router.register(r'draw-logs', views.DrawLogViewSet)
router.register(r'failed-api-calls', views.FailedApiCallViewSet)
router.register(r'logs', views.LogViewSet)
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)
### biz
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)),
path('sync-data/', SynchronizationApi.as_view(), name="data"),
path('data-access-content/', UserDataAccessApi.as_view(), name="data-access-content"),
path("is_granted_unlimited_access/", views.is_granted_unlimited_access, name="is-granted-unlimited-access"),
path('api-token-auth/', obtain_auth_token, name='api_token_auth'),
path("user-by-token/", views.user_by_token, name="user_by_token"),
path('refund-tournament/<str:team_registration_id>/', views.process_refund, name='process-refund'),
path('validate-stripe-account/', views.validate_stripe_account, name='validate_stripe_account'),
path('xls-to-csv/', views.xls_to_csv, name='xls-to-csv'),
path('config/tournament/', views.get_tournament_config, name='tournament-config'),
path('config/payment/', views.get_payment_config, name='payment-config'),
path('fft/club-tournaments/', views.get_fft_club_tournaments, name='get-fft-club-tournaments'),
path('fft/all-tournaments/', views.get_fft_all_tournaments, name='get-fft-all-tournaments'),
path('fft/umpire/<str:tournament_id>/', views.get_fft_umpire_data, name='get-fft-umpire-data'),
path('fft/federal-clubs/', views.get_fft_federal_clubs, name='get-fft-federal-clubs'),
path("change-password/", views.ChangePasswordView.as_view(), name="change_password"),
# authentication
path("change-password/", ChangePasswordView.as_view(), name="change_password"),
path('token-auth/', CustomAuthToken.as_view()),
path('api-token-logout/', Logout.as_view()),
path('token-auth/', views.CustomAuthToken.as_view()),
path('api-token-logout/', views.Logout.as_view()),
# forgotten password
path('dj-rest-auth/', include('dj_rest_auth.urls')),
path('stripe/create-account/', views.create_stripe_connect_account, name='create_stripe_account'),
path('stripe/create-account-link/', views.create_stripe_account_link, name='create_account_link'),
path('resend-payment-email/<str:team_registration_id>/', views.resend_payment_email, name='resend-payment-email'),
path('payment-link/<str:team_registration_id>/', views.get_payment_link, name='get-payment-link'),
]

File diff suppressed because it is too large Load Diff

@ -1,50 +1,78 @@
from pandas.core.groupby import base
from rest_framework import viewsets
from pandas.io.feather_format import pd
from tournaments.models.draw_log import DrawLog
from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, ChangePasswordSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, LiveMatchSerializer, PurchaseSerializer, UserUpdateSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer
from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer
from rest_framework import viewsets, permissions
from rest_framework.authtoken.models import Token
from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes
from rest_framework.decorators import api_view
from rest_framework import status
from rest_framework.generics import UpdateAPIView
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.permissions import IsAuthenticated
from .authentication import HasAPIKey
from rest_framework.views import APIView
from django.http import Http404
from django.conf import settings
from django.http import Http404, HttpResponse, JsonResponse
from django.contrib.auth import authenticate
from django.db.models import Q
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.shortcuts import get_object_or_404
from django.core.exceptions import ObjectDoesNotExist
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, ActivitySerializer, ProspectSerializer, EntitySerializer, TournamentSummarySerializer
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 tournaments.services.email_service import TournamentEmailService
from .permissions import IsClubOwner
from .utils import is_valid_email, check_version_smaller_than_1_1_12
from biz.models import Activity, Prospect, Entity
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework.decorators import api_view
from rest_framework import status
from rest_framework.exceptions import MethodNotAllowed
from shared.discord import send_discord_log_message
from django.http import Http404
from django.db.models import Q
@method_decorator(csrf_exempt, name='dispatch')
class CustomAuthToken(APIView):
permission_classes = []
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, scrape_fft_all_tournaments_concurrent, scrape_federal_clubs
from shared.discord import send_discord_log_message
def post(self, request, *args, **kwargs):
username = request.data.get('username')
password = request.data.get('password')
device_id = request.data.get('device_id')
user = authenticate(username=username, password=password)
if user is None and is_valid_email(username) == True:
true_username = self.get_username_from_email(username)
user = authenticate(username=true_username, password=password)
if user is not None:
if user.device_id is None or user.device_id == device_id or user.username == 'apple-test':
user.device_id = device_id
user.save()
token, created = Token.objects.get_or_create(user=user)
return Response({'token': token.key})
else:
return Response({'error': 'Vous ne pouvez pour l\'instant vous connecter sur plusieurs appareils en même temps. Veuillez vous déconnecter du précédent appareil. Autrement, veuillez contacter le support.'}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({'error': 'L\'utilisateur et le mot de passe de correspondent pas'}, status=status.HTTP_401_UNAUTHORIZED)
def get_username_from_email(self, email):
try:
user = CustomUser.objects.get(email=email)
return user.username
except ObjectDoesNotExist:
return None
from tournaments.services.payment_service import PaymentService
from tournaments.utils.extensions import create_random_filename
class Logout(APIView):
permission_classes = (IsAuthenticated,)
import stripe
import json
import pandas as pd
def post(self, request, *args, **kwargs):
# request.user.auth_token.delete()
import os
import logging
from datetime import datetime
device_id = request.data.get('device_id')
if request.user.device_id == device_id:
request.user.device_id = None
request.user.save()
logger = logging.getLogger(__name__)
return Response(status=status.HTTP_200_OK)
@api_view(['GET'])
def user_by_token(request):
@ -60,7 +88,7 @@ class SoftDeleteViewSet(viewsets.ModelViewSet):
class UserViewSet(SoftDeleteViewSet):
queryset = CustomUser.objects.all()
serializer_class = CustomUserSerializer
serializer_class = UserUpdateSerializer
permission_classes = [] # Users are public whereas the other requests are only for logged users
def get_serializer_class(self):
@ -77,32 +105,6 @@ class ClubViewSet(SoftDeleteViewSet):
def perform_create(self, serializer):
serializer.save(creator=self.request.user)
class TournamentSummaryViewSet(SoftDeleteViewSet):
queryset = Tournament.objects.all()
serializer_class = TournamentSummarySerializer
permission_classes = [HasAPIKey]
def get_queryset(self):
if self.request.user.is_anonymous:
return Tournament.objects.none()
queryset = self.queryset.filter(
Q(event__creator=self.request.user) | Q(related_user=self.request.user)
).distinct()
# Add min_start_date filtering
min_start_date = self.request.query_params.get('min_start_date')
if min_start_date:
try:
# Parse the date string (assumes ISO format: YYYY-MM-DD)
min_date = datetime.fromisoformat(min_start_date).date()
queryset = queryset.filter(start_date__gte=min_date)
except (ValueError, TypeError):
# If date parsing fails, ignore the filter
pass
return queryset
class TournamentViewSet(SoftDeleteViewSet):
queryset = Tournament.objects.all()
serializer_class = TournamentSerializer
@ -110,13 +112,7 @@ class TournamentViewSet(SoftDeleteViewSet):
def get_queryset(self):
if self.request.user.is_anonymous:
return []
return self.queryset.filter(
Q(event__creator=self.request.user))
return self.queryset.filter(
Q(event__creator=self.request.user) | Q(related_user=self.request.user)
).distinct()
return self.queryset.filter(event__creator=self.request.user)
def perform_create(self, serializer):
serializer.save()
@ -155,6 +151,20 @@ class PurchaseViewSet(SoftDeleteViewSet):
def delete(self, request, pk):
raise MethodNotAllowed('DELETE')
class ChangePasswordView(UpdateAPIView):
serializer_class = ChangePasswordSerializer
def update(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
# if using drf authtoken, create a new token
if hasattr(user, 'auth_token'):
user.auth_token.delete()
token, created = Token.objects.get_or_create(user=user)
# return new token
return Response({'token': token.key}, status=status.HTTP_200_OK)
class EventViewSet(SoftDeleteViewSet):
queryset = Event.objects.all()
serializer_class = EventSerializer
@ -162,17 +172,14 @@ class EventViewSet(SoftDeleteViewSet):
def get_queryset(self):
if self.request.user.is_anonymous:
return []
# return self.queryset.filter(creator=self.request.user)
return self.queryset.filter(
Q(creator=self.request.user)
)
return self.queryset.filter(creator=self.request.user)
class RoundViewSet(SoftDeleteViewSet):
queryset = Round.objects.all()
serializer_class = RoundSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(tournament=tournament_id)
if self.request.user:
@ -184,7 +191,7 @@ class GroupStageViewSet(SoftDeleteViewSet):
serializer_class = GroupStageSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(tournament=tournament_id)
if self.request.user:
@ -196,7 +203,7 @@ class MatchViewSet(SoftDeleteViewSet):
serializer_class = MatchSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(Q(group_stage__tournament=tournament_id) | Q(round__tournament=tournament_id))
if self.request.user:
@ -208,7 +215,7 @@ class TeamScoreViewSet(SoftDeleteViewSet):
serializer_class = TeamScoreSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('tournament')
if tournament_id:
q = Q(team_registration__tournament=tournament_id) | Q(match__group_stage__tournament=tournament_id) | Q(match__round__tournament=tournament_id)
return self.queryset.filter(q)
@ -221,7 +228,7 @@ class TeamRegistrationViewSet(SoftDeleteViewSet):
serializer_class = TeamRegistrationSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(tournament=tournament_id)
if self.request.user:
@ -233,7 +240,7 @@ class PlayerRegistrationViewSet(SoftDeleteViewSet):
serializer_class = PlayerRegistrationSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(team_registration__tournament=tournament_id)
if self.request.user:
@ -249,12 +256,9 @@ class DateIntervalViewSet(SoftDeleteViewSet):
serializer_class = DateIntervalSerializer
def get_queryset(self):
if self.request.user.is_anonymous:
return []
return self.queryset.filter(
Q(event__creator=self.request.user)
)
if self.request.user:
return self.queryset.filter(event__creator=self.request.user)
return []
class FailedApiCallViewSet(viewsets.ModelViewSet):
queryset = FailedApiCall.objects.all()
@ -312,7 +316,7 @@ class DrawLogViewSet(SoftDeleteViewSet):
serializer_class = DrawLogSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('tournament') or self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(tournament=tournament_id)
if self.request.user:
@ -342,635 +346,3 @@ class UnregisteredPlayerViewSet(SoftDeleteViewSet):
if self.request.user:
return self.queryset.filter(unregistered_team__tournament__event__creator=self.request.user)
return []
class SupervisorViewSet(viewsets.ModelViewSet):
queryset = CustomUser.objects.all()
serializer_class = ShortUserSerializer
permission_classes = [] # Users are public whereas the other requests are only for logged users
def get_queryset(self):
return self.request.user.supervisors
class ImageViewSet(viewsets.ModelViewSet):
"""
Viewset for handling event image uploads and retrieval.
This allows umpires/organizers to upload images for events from the iOS app,
which can then be displayed on the event pages.
"""
serializer_class = ImageSerializer
queryset = Image.objects.all()
def get_queryset(self):
queryset = Image.objects.all()
# Filter by event
event_id = self.request.query_params.get('event_id')
image_type = self.request.query_params.get('image_type')
if event_id:
queryset = queryset.filter(event_id=event_id)
if image_type:
queryset = queryset.filter(image_type=image_type)
return queryset
def perform_create(self, serializer):
serializer.save()
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def process_refund(request, team_registration_id):
try:
# Verify the user is the tournament umpire
team_registration = get_object_or_404(TeamRegistration, id=team_registration_id)
if request.user != team_registration.tournament.event.creator:
return Response({
'success': False,
'message': "Vous n'êtes pas autorisé à effectuer ce remboursement"
}, status=403)
payment_service = PaymentService(request)
players_serializer = PlayerRegistrationSerializer(team_registration.players_sorted_by_rank, many=True)
success, message, refund = payment_service.process_refund(team_registration_id, force_refund=True)
return Response({
'success': success,
'message': message,
'players': players_serializer.data
})
except Exception as e:
return Response({
'success': False,
'message': str(e)
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def xls_to_csv(request):
# Check if the request has a file
if 'file' in request.FILES:
uploaded_file = request.FILES['file']
# Save the uploaded file
directory = 'tmp/csv/'
file_path = os.path.join(directory, uploaded_file.name)
file_name = default_storage.save(file_path, ContentFile(uploaded_file.read()))
logger.info(f'file saved at {file_name}')
full_path = default_storage.path(file_name)
logger.info(f'full_path = {full_path}')
# Check available sheets and look for 'inscriptions'
xls = pd.ExcelFile(full_path)
sheet_names = xls.sheet_names
# Determine which sheet to use
target_sheet = 0 # Default to first sheet
if 'inscriptions' in [name.lower() for name in sheet_names]:
for i, name in enumerate(sheet_names):
if name.lower() == 'inscriptions':
target_sheet = i # or use the name directly: target_sheet = name
break
# Convert to csv and save
data_xls = pd.read_excel(full_path, sheet_name=target_sheet, index_col=None)
csv_file_name = create_random_filename('players', 'csv')
output_path = os.path.join(directory, csv_file_name)
full_output_path = default_storage.path(output_path)
data_xls.to_csv(full_output_path, sep=';', index=False, encoding='utf-8')
# Send the processed file back
with default_storage.open(full_output_path, 'rb') as file:
response = HttpResponse(file.read(), content_type='application/octet-stream')
response['Content-Disposition'] = f'attachment; filename="players.csv"'
# Clean up: delete both files
default_storage.delete(file_path)
default_storage.delete(output_path)
return response
else:
return HttpResponse("No file was uploaded", status=400)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_tournament_config(request):
"""Return tournament-related configuration settings"""
config = settings.TOURNAMENT_SETTINGS
return Response({
'time_proximity_rules': config['TIME_PROXIMITY_RULES'],
'waiting_list_rules': config['WAITING_LIST_RULES'],
'business_rules': config['BUSINESS_RULES'],
'minimum_response_time': config['MINIMUM_RESPONSE_TIME']
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_payment_config(request):
"""Return payment-related configuration settings"""
return Response({
'stripe_fee': getattr(settings, 'STRIPE_FEE', 0)
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def create_stripe_connect_account(request):
stripe.api_key = settings.STRIPE_SECRET_KEY
user = request.user
try:
# Create a new Standard account
account = stripe.Account.create(
type='standard',
metadata={
'padelclub_email': user.email,
'platform': 'padelclub'
}
)
return Response({
'success': True,
'account_id': account.id,
})
except Exception as e:
return Response({
'success': False,
'error': str(e)
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def create_stripe_account_link(request):
"""
Create an account link for a Stripe account.
Uses HTTPS URLs only - no custom URL schemes.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
# Parse request data
data = json.loads(request.body)
account_id = data.get('account_id')
if not account_id:
return Response({
'success': False,
'error': 'No Stripe account ID found'
}, status=400)
try:
# Force HTTPS for production Stripe calls
if hasattr(settings, 'STRIPE_MODE') and settings.STRIPE_MODE == 'live':
base_path = f"https://{request.get_host()}"
else:
base_path = f"{request.scheme}://{request.get_host()}"
# print("create_stripe_account_link", base_path)
refresh_url = f"{base_path}/stripe-refresh-account-link/"
return_url = f"{base_path}/stripe-onboarding-complete/"
# Generate the account link URL
account_link = stripe.AccountLink.create(
account=account_id,
refresh_url=refresh_url,
return_url=return_url,
type='account_onboarding',
)
return Response({
'success': True,
'url': account_link.url,
'account_id': account_id
})
except Exception as e:
return Response({
'success': False,
'error': str(e)
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def validate_stripe_account(request):
"""
Validate a Stripe account for a tournament.
Returns validation status and onboarding URL if needed.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
# Parse the request body
data = json.loads(request.body)
account_id = data.get('account_id')
if not account_id:
return Response({
'valid': False,
'error': 'No account ID found to validate',
'needs_onboarding': True
}, status=200)
try:
# Validate the account with Stripe
account = stripe.Account.retrieve(account_id)
# Check account capabilities
charges_enabled = account.get('charges_enabled', False)
payouts_enabled = account.get('payouts_enabled', False)
details_submitted = account.get('details_submitted', False)
# Determine if the account is valid and ready
is_valid = account.id is not None
can_process_payments = charges_enabled and payouts_enabled
onboarding_complete = details_submitted
needs_onboarding = not (can_process_payments and onboarding_complete)
return Response({
'valid': is_valid,
'can_process_payments': can_process_payments,
'onboarding_complete': onboarding_complete,
'needs_onboarding': needs_onboarding,
'account': {
'id': account.id,
'charges_enabled': charges_enabled,
'payouts_enabled': payouts_enabled,
'details_submitted': details_submitted
}
})
except stripe.error.PermissionError:
# Account doesn't exist or isn't connected to your platform
return Response({
'valid': False,
'error': 'This Stripe account is not connected to your platform or does not exist.',
'needs_onboarding': True,
}, status=200)
except stripe.error.InvalidRequestError:
return Response({
'valid': False,
'error': 'Invalid account ID format',
'needs_onboarding': True,
}, status=200)
except Exception as e:
return Response({
'valid': False,
'error': f'Unexpected error: {str(e)}',
'needs_onboarding': True,
}, status=200)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def resend_payment_email(request, team_registration_id):
"""
Resend the registration confirmation email (which includes payment info/link)
"""
try:
team_registration = TeamRegistration.objects.get(id=team_registration_id)
tournament = team_registration.tournament
TournamentEmailService.send_registration_confirmation(
request,
tournament,
team_registration,
waiting_list_position=-1,
force_send=True
)
return Response({
'success': True,
'message': 'Email de paiement renvoyé'
})
except TeamRegistration.DoesNotExist:
return Response({'error': 'Team not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_payment_link(request, team_registration_id):
"""
Get payment link for a team registration.
Only accessible by the umpire (tournament creator).
"""
try:
team_registration = get_object_or_404(TeamRegistration, id=team_registration_id)
# Check if the user is the umpire (creator) of the tournament
if request.user != team_registration.tournament.event.creator:
return Response({
'success': False,
'message': "Vous n'êtes pas autorisé à accéder à ce lien de paiement"
}, status=403)
# Create payment link
payment_link = PaymentService.create_payment_link(team_registration.id)
if payment_link:
return Response({
'success': True,
'payment_link': payment_link
})
else:
return Response({
'success': False,
'message': 'Impossible de créer le lien de paiement'
}, status=500)
except TeamRegistration.DoesNotExist:
return Response({'error': 'Team not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def is_granted_unlimited_access(request):
can_create = False
if request.user and request.user.is_anonymous == False and request.user.organising_for:
for owner in request.user.organising_for.all():
purchases = Purchase.objects.filter(user=owner,product_id='app.padelclub.tournament.subscription.unlimited')
for purchase in purchases:
if purchase.is_active():
can_create = True
return JsonResponse({'can_create': can_create}, status=status.HTTP_200_OK)
@api_view(['GET', 'POST'])
@permission_classes([])
def get_fft_club_tournaments(request):
"""
API endpoint to get tournaments for a specific club
Handles pagination automatically to get all results
"""
try:
if request.method == 'POST':
data = request.data
else:
data = request.GET
club_code = data.get('club_code', '62130180')
club_name = data.get('club_name', 'TENNIS SPORTING CLUB DE CASSIS')
start_date = data.get('start_date')
end_date = data.get('end_date')
paginate = data.get('paginate', 'true').lower() == 'true'
if paginate:
# Get all pages automatically (matching Swift behavior)
result = scrape_fft_club_tournaments_all_pages(
club_code=club_code,
club_name=club_name,
start_date=start_date,
end_date=end_date
)
else:
# Get single page
page = int(data.get('page', 0))
result = scrape_fft_club_tournaments(
club_code=club_code,
club_name=club_name,
start_date=start_date,
end_date=end_date,
page=page
)
if result:
return JsonResponse({
'success': True,
'tournaments': result.get('tournaments', []),
'total_results': result.get('total_results', 0),
'current_count': result.get('current_count', 0),
'pages_scraped': result.get('pages_scraped', 1),
'message': f'Successfully scraped {len(result.get("tournaments", []))} tournaments'
}, status=status.HTTP_200_OK)
else:
return JsonResponse({
'success': False,
'tournaments': [],
'total_results': 0,
'current_count': 0,
'message': 'Failed to scrape club tournaments'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"Error in get_fft_club_tournaments endpoint: {e}")
return JsonResponse({
'success': False,
'tournaments': [],
'message': f'Unexpected error: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([])
def get_fft_umpire_data(request, tournament_id):
"""
API endpoint to get umpire data for a specific tournament
Returns data that can be used to populate japPhoneNumber field
"""
try:
name, email, phone = get_umpire_data(tournament_id)
return JsonResponse({
'name': name,
'email': email,
'phone': phone
}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error in get_fft_umpire_data endpoint: {e}")
return JsonResponse({
'success': False,
'umpire': None,
'japPhoneNumber': None,
'message': f'Unexpected error: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET', 'POST'])
@permission_classes([])
def get_fft_all_tournaments(request):
"""
API endpoint to get tournaments with smart pagination:
- page=0: Returns first page + metadata about total pages
- page>0: Returns all remaining pages concurrently
"""
try:
if request.method == 'POST':
data = request.data
else:
data = request.GET
# Extract parameters
sorting_option = data.get('sorting_option', 'dateDebut+asc')
page = int(data.get('page', 0))
start_date = data.get('start_date')
end_date = data.get('end_date')
city = data.get('city', '')
distance = int(data.get('distance', 15))
categories = data.getlist('categories') if hasattr(data, 'getlist') else data.get('categories', [])
levels = data.getlist('levels') if hasattr(data, 'getlist') else data.get('levels', [])
lat = data.get('lat')
lng = data.get('lng')
ages = data.getlist('ages') if hasattr(data, 'getlist') else data.get('ages', [])
tournament_types = data.getlist('types') if hasattr(data, 'getlist') else data.get('types', [])
national_cup = data.get('national_cup', 'false').lower() == 'true'
max_workers = int(data.get('max_workers', 5))
if page == 0:
# Handle first page individually
result = scrape_fft_all_tournaments(
sorting_option=sorting_option,
page=0,
start_date=start_date,
end_date=end_date,
city=city,
distance=distance,
categories=categories,
levels=levels,
lat=lat,
lng=lng,
ages=ages,
tournament_types=tournament_types,
national_cup=national_cup
)
if result:
tournaments = result.get('tournaments', [])
total_results = result.get('total_results', 0)
results_per_page = len(tournaments)
# Calculate total pages
if results_per_page > 0:
total_pages = (total_results + results_per_page - 1) // results_per_page
else:
total_pages = 1
return JsonResponse({
'success': True,
'tournaments': tournaments,
'total_results': total_results,
'current_count': len(tournaments),
'page': 0,
'total_pages': total_pages,
'has_more_pages': total_pages > 1,
'message': f'Successfully scraped page 0: {len(tournaments)} tournaments. Total: {total_results} across {total_pages} pages.'
}, status=status.HTTP_200_OK)
else:
return JsonResponse({
'success': False,
'tournaments': [],
'total_results': 0,
'current_count': 0,
'page': 0,
'total_pages': 0,
'has_more_pages': False,
'message': 'Failed to scrape first page'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
# Handle all remaining pages concurrently
result = scrape_fft_all_tournaments_concurrent(
sorting_option=sorting_option,
start_date=start_date,
end_date=end_date,
city=city,
distance=distance,
categories=categories,
levels=levels,
lat=lat,
lng=lng,
ages=ages,
tournament_types=tournament_types,
national_cup=national_cup,
max_workers=max_workers
)
if result:
return JsonResponse({
'success': True,
'tournaments': result.get('tournaments', []),
'total_results': result.get('total_results', 0),
'current_count': result.get('current_count', 0),
'pages_scraped': result.get('pages_scraped', 0),
'message': f'Successfully scraped {result.get("pages_scraped", 0)} remaining pages concurrently: {len(result.get("tournaments", []))} tournaments'
}, status=status.HTTP_200_OK)
else:
return JsonResponse({
'success': False,
'tournaments': [],
'total_results': 0,
'current_count': 0,
'pages_scraped': 0,
'message': 'Failed to scrape remaining pages'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"Error in get_fft_all_tournaments endpoint: {e}")
return JsonResponse({
'success': False,
'tournaments': [],
'message': f'Unexpected error: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET', 'POST'])
@permission_classes([])
def get_fft_federal_clubs(request):
"""
API endpoint to get federal clubs with filters
"""
try:
if request.method == 'POST':
data = request.data
else:
data = request.GET
# Extract parameters - matching the Swift query parameters
country = data.get('country', 'fr')
city = data.get('city', '')
radius = float(data.get('radius', 15))
latitude = data.get('lat')
longitude = data.get('lng')
# Convert latitude and longitude to float if provided
if latitude:
latitude = float(latitude)
if longitude:
longitude = float(longitude)
result = scrape_federal_clubs(
country=country,
city=city,
latitude=latitude,
longitude=longitude,
radius=radius
)
if result:
# Return the result directly as JSON (already in correct format)
return JsonResponse(result, status=status.HTTP_200_OK)
else:
# Return error in expected format
return JsonResponse({
"typeRecherche": "clubs",
"nombreResultat": 0,
"club_markers": []
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"Error in get_fft_federal_clubs endpoint: {e}")
return JsonResponse({
"typeRecherche": "clubs",
"nombreResultat": 0,
"club_markers": []
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
### biz
class CRMActivityViewSet(SoftDeleteViewSet):
queryset = Activity.objects.all()
serializer_class = ActivitySerializer
class CRMProspectViewSet(SoftDeleteViewSet):
queryset = Prospect.objects.all()
serializer_class = ProspectSerializer
class CRMEntityViewSet(SoftDeleteViewSet):
queryset = Entity.objects.all()
serializer_class = EntitySerializer

@ -1,15 +0,0 @@
from django.contrib import admin
from .models import Device, LoginLog
class DeviceAdmin(admin.ModelAdmin):
list_display = ['user', 'device_model', 'last_login', 'id']
readonly_fields = ('last_login',)
ordering = ['-last_login']
class LoginLogAdmin(admin.ModelAdmin):
list_display = ['user', 'device', 'date']
ordering = ['-date']
admin.site.register(Device, DeviceAdmin)
admin.site.register(LoginLog, LoginLogAdmin)

@ -1,6 +0,0 @@
from django.apps import AppConfig
class AuthenticationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'authentication'

@ -1,36 +0,0 @@
# Generated by Django 5.1 on 2025-03-20 14:49
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Device',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('last_login', models.DateTimeField(auto_now=True)),
('model_name', models.CharField(blank=True, max_length=100, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devices', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='LoginLog',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('date', models.DateTimeField(auto_now=True)),
('device', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='login_logs', to='authentication.device')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_logs', to=settings.AUTH_USER_MODEL)),
],
),
]

@ -1,18 +0,0 @@
# Generated by Django 5.1 on 2025-03-20 15:42
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('authentication', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='device',
old_name='model_name',
new_name='device_model',
),
]

@ -1,2 +0,0 @@
from .device import Device
from .login_log import LoginLog

@ -1,12 +0,0 @@
from django.db import models
import uuid
from django.conf import settings
class Device(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='devices')
last_login = models.DateTimeField(auto_now=True)
device_model = models.CharField(max_length=100, blank=True, null=True)
def __str__(self):
return f"{self.user.username} : {self.device_model}"

@ -1,13 +0,0 @@
from django.db import models
import uuid
from django.conf import settings
from . import Device
class LoginLog(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='login_logs')
device = models.ForeignKey(Device, on_delete=models.SET_NULL, related_name='login_logs', null=True)
date = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.id} > {self.user.username}"

@ -1,29 +0,0 @@
from django.contrib.auth import password_validation
from rest_framework import serializers
class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(max_length=128, write_only=True, required=True)
new_password1 = serializers.CharField(max_length=128, write_only=True, required=True)
new_password2 = serializers.CharField(max_length=128, write_only=True, required=True)
def validate_old_password(self, value):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError(
_('Your old password was entered incorrectly. Please enter it again.')
)
return value
def validate(self, data):
if data['new_password1'] != data['new_password2']:
raise serializers.ValidationError({'new_password2': _("The two password fields didn't match.")})
password_validation.validate_password(data['new_password1'], self.context['request'].user)
return data
def save(self, **kwargs):
password = self.validated_data['new_password1']
user = self.context['request'].user
user.set_password(password)
user.save()
return user

@ -1,5 +0,0 @@
import re
def is_valid_email(email):
email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$'
return re.match(email_regex, email) is not None

@ -1,116 +0,0 @@
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth import authenticate
from django.utils.decorators import method_decorator
from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings
from django.contrib.auth import get_user_model
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.authtoken.models import Token
from rest_framework import status
from rest_framework.generics import UpdateAPIView
from .utils import is_valid_email
from .models import Device, LoginLog
from .serializers import ChangePasswordSerializer
import logging
CustomUser=get_user_model()
logger = logging.getLogger(__name__)
@method_decorator(csrf_exempt, name='dispatch')
class CustomAuthToken(APIView):
permission_classes = []
def post(self, request, *args, **kwargs):
username = request.data.get('username')
password = request.data.get('password')
device_id = request.data.get('device_id')
# logger.info(f'Login attempt from {username}')
user = authenticate(username=username, password=password)
if user is None and is_valid_email(username) == True:
true_username = self.get_username_from_email(username)
user = authenticate(username=true_username, password=password)
if user:
user.device_id = device_id
user.save()
device_model = request.data.get('device_model')
device = self.create_or_update_device(user, device_id, device_model)
self.create_login_log(user, device)
token, created = Token.objects.get_or_create(user=user)
return Response({'token': token.key})
# if user.device_id is None or user.device_id == device_id or user.username == 'apple-test':
# user.device_id = device_id
# user.save()
# device_model = request.data.get('device_model')
# device = self.create_or_update_device(user, device_id, device_model)
# self.create_login_log(user, device)
# token, created = Token.objects.get_or_create(user=user)
# return Response({'token': token.key})
# else:
# return Response({'error': 'Vous ne pouvez pour l\'instant vous connecter sur plusieurs appareils en même temps. Veuillez vous déconnecter du précédent appareil. Autrement, veuillez contacter le support.'}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({'error': 'L\'utilisateur et le mot de passe de correspondent pas'}, status=status.HTTP_401_UNAUTHORIZED)
def create_or_update_device(self, user, device_id, device_model):
obj, created = Device.objects.update_or_create(
id=device_id,
device_model=device_model,
defaults={
'user': user
}
)
return obj
def create_login_log(self, user, device):
LoginLog.objects.create(user=user, device=device)
def get_username_from_email(self, email):
try:
user = CustomUser.objects.get(email=email)
return user.username
except ObjectDoesNotExist:
return None
class Logout(APIView):
permission_classes = (IsAuthenticated,)
def post(self, request, *args, **kwargs):
# request.user.auth_token.delete()
device_id = request.data.get('device_id')
if request.user.device_id == device_id:
request.user.device_id = None
request.user.save()
Device.objects.filter(id=device_id).delete()
return Response(status=status.HTTP_200_OK)
class ChangePasswordView(UpdateAPIView):
serializer_class = ChangePasswordSerializer
def update(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
# if using drf authtoken, create a new token
if hasattr(user, 'auth_token'):
user.auth_token.delete()
token, created = Token.objects.get_or_create(user=user)
# return new token
return Response({'token': token.key}, status=status.HTTP_200_OK)

@ -1,534 +0,0 @@
from django.http import HttpResponseRedirect
from django.contrib import admin
from django.urls import path, reverse
from django.contrib import messages
from django.shortcuts import render, redirect
from django.contrib.auth import get_user_model
from django.utils.html import format_html
from django.core.mail import send_mail
from django.db.models import Q, Max, Subquery, OuterRef
import csv
import io
import time
import logging
from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate, DeclinationReason, ProspectGroup
from .forms import FileImportForm, EmailTemplateSelectionForm
from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter
from tournaments.models import CustomUser
from tournaments.models.enums import UserOrigin
from sync.admin import SyncedObjectAdmin
User = get_user_model()
logger = logging.getLogger(__name__)
class ProspectInline(admin.StackedInline):
model = Prospect.entities.through
extra = 1
verbose_name = "Prospect"
verbose_name_plural = "Prospects"
autocomplete_fields = ['prospect']
@admin.register(Entity)
class EntityAdmin(SyncedObjectAdmin):
list_display = ('name', 'address', 'zip_code', 'city')
search_fields = ('name', 'address', 'zip_code', 'city')
# filter_horizontal = ('prospects',)
inlines = [ProspectInline]
@admin.register(EmailTemplate)
class EmailTemplateAdmin(SyncedObjectAdmin):
list_display = ('name', 'subject', 'body')
search_fields = ('name', 'subject')
exclude = ('data_access_ids', 'activities',)
def contacted_by_sms(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, ActivityType.SMS, Status.CONTACTED, None)
contacted_by_sms.short_description = "Contacted by SMS"
def mark_as_inbound(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.INBOUND, None)
mark_as_inbound.short_description = "Mark as inbound"
def mark_as_customer(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.CUSTOMER, None)
mark_as_customer.short_description = "Mark as customer"
def mark_as_should_test(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.SHOULD_TEST, None)
mark_as_should_test.short_description = "Mark as should test"
def mark_as_testing(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.TESTING, None)
mark_as_testing.short_description = "Mark as testing"
def declined_too_expensive(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.TOO_EXPENSIVE)
declined_too_expensive.short_description = "Declined too expensive"
def declined_use_something_else(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_OTHER_PRODUCT)
declined_use_something_else.short_description = "Declined use something else"
def declined_android_user(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_ANDROID)
declined_android_user.short_description = "Declined use Android"
def mark_as_have_account(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.HAVE_CREATED_ACCOUNT, None)
mark_as_have_account.short_description = "Mark as having an account"
def mark_as_not_concerned(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.NOT_CONCERNED, None)
mark_as_not_concerned.short_description = "Mark as not concerned"
def create_default_activity_for_prospect(modeladmin, request, queryset, type, status, reason):
for prospect in queryset:
activity = Activity.objects.create(
type=type,
status=status,
declination_reason=reason,
related_user = request.user
)
activity.prospects.add(prospect)
modeladmin.message_user(
request,
f'{queryset.count()} prospects were marked as {status}.'
)
def create_activity_for_prospect(modeladmin, request, queryset):
# Only allow single selection
if queryset.count() != 1:
messages.error(request, "Please select exactly one prospect.")
return
prospect = queryset.first()
# Build the URL with pre-populated fields
url = reverse('admin:biz_activity_add')
url += f'?prospect={prospect.id}'
return redirect(url)
create_activity_for_prospect.short_description = "Create activity"
@admin.register(Prospect)
class ProspectAdmin(SyncedObjectAdmin):
readonly_fields = ['related_activities', 'entity_names', 'current_status', 'id']
fieldsets = [
(None, {
'fields': ['related_activities', 'id', 'entity_names', 'first_name', 'last_name', 'email', 'phone', 'contact_again', 'official_user', 'name_unsure', 'entities', 'related_user']
}),
]
list_display = ('first_name', 'last_name', 'entity_names', 'phone', 'last_update_date', 'current_status', 'contact_again')
list_filter = (ContactAgainFilter, ProspectStatusFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter, 'creation_date', StaffUserFilter, 'source', ProspectProfileFilter)
search_fields = ('first_name', 'last_name', 'email', 'phone')
date_hierarchy = 'creation_date'
change_list_template = "admin/biz/prospect/change_list.html"
ordering = ['-last_update']
filter_horizontal = ['entities']
actions = ['send_email', create_activity_for_prospect, mark_as_inbound, contacted_by_sms, mark_as_should_test, mark_as_testing, mark_as_customer, mark_as_have_account, declined_too_expensive, declined_use_something_else, declined_android_user, mark_as_not_concerned]
autocomplete_fields = ['official_user', 'related_user']
def save_model(self, request, obj, form, change):
if obj.related_user is None:
obj.related_user = request.user
super().save_model(request, obj, form, change)
def last_update_date(self, obj):
return obj.last_update.date() if obj.last_update else None
last_update_date.short_description = 'Last Update'
last_update_date.admin_order_field = 'last_update'
def related_activities(self, obj):
activities = obj.activities.all()
if activities:
activity_links = []
for activity in activities:
url = f"/kingdom/biz/activity/{activity.id}/change/"
activity_links.append(f'<a href="{url}">{activity.html_desc()}</a>')
return format_html('<br>'.join(activity_links))
return "No events"
related_activities.short_description = "Related Activities"
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('dashboard/', self.admin_site.admin_view(self.dashboard), name='biz_dashboard'),
path('import_file/', self.admin_site.admin_view(self.import_file), name='import_file'),
path('import_app_users/', self.admin_site.admin_view(self.import_app_users), name='import_app_users'),
path('cleanup/', self.admin_site.admin_view(self.cleanup), name='cleanup'),
]
return custom_urls + urls
def dashboard(self, request):
"""
Dashboard view showing prospects organized by status columns
"""
# Get filter parameter - if 'my' is true, filter by current user
filter_my = request.GET.get('my', 'false') == 'true'
# Base queryset
base_queryset = Prospect.objects.select_related().prefetch_related('entities', 'activities')
# Apply user filter if requested
if filter_my:
base_queryset = base_queryset.filter(related_user=request.user)
# Helper function to get prospects by status
def get_prospects_by_status(statuses):
# Get the latest activity status for each prospect
latest_activity = Activity.objects.filter(
prospects=OuterRef('pk'),
status__isnull=False
).order_by('-creation_date')
prospects = base_queryset.annotate(
latest_status=Subquery(latest_activity.values('status')[:1])
).filter(
latest_status__in=statuses
).order_by('last_update')
return prospects
# Get prospects for each column
should_test_prospects = get_prospects_by_status([Status.SHOULD_TEST])
testing_prospects = get_prospects_by_status([Status.TESTING])
responded_prospects = get_prospects_by_status([Status.RESPONDED])
others_prospects = get_prospects_by_status([Status.INBOUND, Status.SHOULD_BUY])
# Get prospects with contact_again date set, sorted by oldest first
contact_again_prospects = base_queryset.filter(
contact_again__isnull=False
).order_by('contact_again')
context = {
'title': 'CRM Dashboard',
'should_test_prospects': should_test_prospects,
'testing_prospects': testing_prospects,
'responded_prospects': responded_prospects,
'others_prospects': others_prospects,
'contact_again_prospects': contact_again_prospects,
'filter_my': filter_my,
'opts': self.model._meta,
'has_view_permission': self.has_view_permission(request),
}
return render(request, 'admin/biz/dashboard.html', context)
def cleanup(self, request):
Entity.objects.all().delete()
Prospect.objects.all().delete()
Activity.objects.all().delete()
messages.success(request, 'cleanup biz objects')
return redirect('admin:biz_prospect_changelist')
def import_app_users(self, request):
users = CustomUser.objects.filter(origin=UserOrigin.APP)
created_count = 0
for user in users:
is_customer = user.purchases.count() > 0
entity_name = user.latest_event_club_name()
prospect, prospect_created = Prospect.objects.get_or_create(
email=user.email,
defaults={
'first_name': user.first_name,
'last_name': user.last_name,
'phone': user.phone,
'name_unsure': False,
'official_user': user,
'source': 'App',
}
)
if entity_name:
entity, entity_created = Entity.objects.get_or_create(
name=entity_name,
defaults={'name': entity_name}
)
prospect.entities.add(entity)
if is_customer:
activity = Activity.objects.create(
status=Status.CUSTOMER,
)
activity.prospects.add(prospect)
if prospect_created:
created_count += 1
messages.success(request, f'Imported {created_count} app users into prospects')
return redirect('admin:biz_prospect_changelist')
def import_file(self, request):
"""
Handle file import - displays form and processes file upload
"""
if request.method == 'POST':
form = FileImportForm(request.POST, request.FILES)
if form.is_valid():
# Call the import_csv method with the uploaded file
try:
result = self.import_csv(form.cleaned_data['file'], form.cleaned_data['source'])
messages.success(request, f'File imported successfully: {result}')
return redirect('admin:biz_prospect_changelist')
except Exception as e:
messages.error(request, f'Error importing file: {str(e)}')
else:
messages.error(request, 'Please correct the errors below.')
else:
form = FileImportForm()
context = {
'form': form,
'title': 'Import File',
'app_label': self.model._meta.app_label,
'opts': self.model._meta,
'has_change_permission': self.has_change_permission(request),
}
return render(request, 'admin/biz/prospect/import_file.html', context)
def import_csv(self, file, source):
"""
Process the uploaded CSV file
CSV format: entity_name,last_name,first_name,email,phone,attachment_text,status,related_user
"""
try:
# Read the file content
file_content = file.read().decode('utf-8')
csv_reader = csv.reader(io.StringIO(file_content), delimiter=';')
created_prospects = 0
updated_prospects = 0
created_entities = 0
created_events = 0
for row in csv_reader:
print(f'>>> row size is {len(row)}')
if len(row) < 5:
print(f'>>> WARNING: row size is {len(row)}: {row}')
continue # Skip rows that don't have enough columns
entity_name = row[0].strip()
last_name = row[1].strip()
first_name = row[2].strip()
email = row[3].strip()
phone = row[4].strip() if row[4].strip() else None
if phone and not phone.startswith('0'):
phone = '0' + phone
# attachment_text = row[5].strip() if row[5].strip() else None
# status_text = row[6].strip() if row[6].strip() else None
# related_user_name = row[7].strip() if row[7].strip() else None
# Create or get Entity
entity = None
if entity_name:
entity, entity_created = Entity.objects.get_or_create(
name=entity_name,
defaults={'name': entity_name}
)
if entity_created:
created_entities += 1
# Get related user if provided
# related_user = None
# if related_user_name:
# try:
# related_user = User.objects.get(username=related_user_name)
# except User.DoesNotExist:
# # Try to find by first name if username doesn't exist
# related_user = User.objects.filter(first_name__icontains=related_user_name).first()
# Create or update Prospect
prospect, prospect_created = Prospect.objects.get_or_create(
email=email,
defaults={
'first_name': first_name,
'last_name': last_name,
'phone': phone,
'name_unsure': False,
'source': source,
}
)
if prospect_created:
created_prospects += 1
# else:
# # Check if names are different and mark as name_unsure
# if (prospect.first_name != first_name or prospect.last_name != last_name):
# prospect.name_unsure = True
# # Update related_user if provided
# if related_user:
# prospect.related_user = related_user
# prospect.save()
# updated_prospects += 1
# Associate entity with prospect
if entity:
prospect.entities.add(entity)
# Create Event if attachment_text or status is provided
# if attachment_text or status_text:
# # Map status text to Status enum
# status_value = None
# declination_reason = None
# if status_text:
# if 'CONTACTED' in status_text:
# status_value = Status.CONTACTED
# elif 'RESPONDED' in status_text:
# status_value = Status.RESPONDED
# elif 'SHOULD_TEST' in status_text:
# status_value = Status.SHOULD_TEST
# elif 'CUSTOMER' in status_text:
# status_value = Status.CUSTOMER
# elif 'TESTING' in status_text:
# status_value = Status.TESTING
# elif 'LOST' in status_text:
# status_value = Status.LOST
# elif 'DECLINED_TOO_EXPENSIVE' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.TOO_EXPENSIVE
# elif 'USE_OTHER_PRODUCT' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.USE_OTHER_PRODUCT
# elif 'USE_ANDROID' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.USE_ANDROID
# elif 'NOK' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.UNKNOWN
# elif 'DECLINED_UNRELATED' in status_text:
# status_value = Status.DECLINED_UNRELATED
# activity = Activity.objects.create(
# type=ActivityType.SMS,
# attachment_text=attachment_text,
# status=status_value,
# declination_reason=declination_reason,
# description=f"Imported from CSV - Status: {status_text}" if status_text else "Imported from CSV"
# )
# activity.prospects.add(prospect)
# created_events += 1
result = f"Successfully imported: {created_prospects} new prospects, {updated_prospects} updated prospects, {created_entities} new entities, {created_events} new events"
return result
except Exception as e:
raise Exception(f"Error processing CSV file: {str(e)}")
def send_email(self, request, queryset):
logger.info('send_email to prospects form initiated...')
if 'apply' in request.POST:
form = EmailTemplateSelectionForm(request.POST)
if form.is_valid():
email_template = form.cleaned_data['email_template']
sent_count, failed_count = self.process_selected_items_with_template(request, queryset, email_template)
if failed_count > 0:
self.message_user(request, f"Email sent to {sent_count} prospects, {failed_count} failed using the '{email_template.name}' template.", messages.WARNING)
else:
self.message_user(request, f"Email sent to {sent_count} prospects using the '{email_template.name}' template.", messages.SUCCESS)
return HttpResponseRedirect(request.get_full_path())
else:
form = EmailTemplateSelectionForm()
return render(request, 'admin/biz/select_email_template.html', {
'prospects': queryset,
'form': form,
'title': 'Send Email to Prospects'
})
send_email.short_description = "Send email"
def process_selected_items_with_template(self, request, queryset, email_template):
sent_count = 0
error_emails = []
all_emails = []
logger.info(f'Sending email to {queryset.count()} users...')
for prospect in queryset:
mail_body = email_template.body.replace(
'{{name}}',
f' {prospect.first_name}' if prospect.first_name and len(prospect.first_name) > 0 else ''
)
# mail_body = email_template.body.replace('{{name}}', prospect.first_name)
all_emails.append(prospect.email)
try:
send_mail(
email_template.subject,
mail_body,
request.user.email,
[prospect.email],
fail_silently=False,
)
sent_count += 1
activity = Activity.objects.create(
type=ActivityType.MAIL,
status=Status.CONTACTED,
description=f"Email sent: {email_template.subject}"
)
activity.prospects.add(prospect)
except Exception as e:
error_emails.append(prospect.email)
logger.error(f'Failed to send email to {prospect.email}: {str(e)}')
time.sleep(1)
if error_emails:
logger.error(f'Failed to send emails to: {error_emails}')
return sent_count, len(error_emails)
@admin.register(ProspectGroup)
class ProspectGroupAdmin(SyncedObjectAdmin):
list_display = ('name', 'user_count')
date_hierarchy = 'creation_date'
raw_id_fields = ['related_user']
@admin.register(Activity)
class ActivityAdmin(SyncedObjectAdmin):
# raw_id_fields = ['prospects']
list_display = ('prospect_names', 'last_update', 'status', 'type', 'description', 'attachment_text', )
list_filter = ('status', 'type')
search_fields = ('attachment_text',)
date_hierarchy = 'last_update'
autocomplete_fields = ['prospects', 'related_user']
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
# Pre-populate fields from URL parameters
if 'prospect' in request.GET:
try:
prospect_id = request.GET['prospect']
prospect = Prospect.objects.get(id=prospect_id)
form.base_fields['prospects'].initial = [prospect]
form.base_fields['related_user'].initial = request.user
# You can set other fields based on the prospect
# form.base_fields['title'].initial = f"Event for {prospect.}"
# form.base_fields['status'].initial = 'pending'
except (Prospect.DoesNotExist, ValueError):
pass
return form
def save_model(self, request, obj, form, change):
if obj.related_user is None:
obj.related_user = request.user
super().save_model(request, obj, form, change)
def get_event_display(self, obj):
return str(obj)
get_event_display.short_description = 'Activity'

@ -1,70 +0,0 @@
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='biz_email_users'),
path('email_users_count/', email_users_count, name='biz_email_count'),
path('email_users_with_tournaments_count/', email_users_with_tournaments_count, name='biz_email_with_tournaments_count'),
path('email_users_with_tournaments/', email_users_with_tournaments, name='email_users_with_tournaments'),
]

@ -1,163 +0,0 @@
from xml.dom import Node
import django_filters
from django.db.models import Max, F, Q
from django.contrib.auth import get_user_model
from django.contrib import admin
from django.utils import timezone
from dateutil.relativedelta import relativedelta
from .models import Activity, Prospect, Status, DeclinationReason, ProspectGroup
User = get_user_model()
class ProspectFilter(django_filters.FilterSet):
zip_code = django_filters.CharFilter(lookup_expr='istartswith', label='Code postal')
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')
def filter_name(self, queryset, name, value):
return queryset.filter(
Q(first_name__icontains=value) | Q(last_name__icontains=value) | Q(entity_name__icontains=value)
)
class Meta:
model = Prospect
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
)
class ProspectStatusFilter(admin.SimpleListFilter):
title = 'Status'
parameter_name = 'status'
def lookups(self, request, model_admin):
return [(tag.name, tag.value) for tag in Status]
def queryset(self, request, queryset):
if self.value() == Status.NONE:
return queryset.filter(activities__isnull=True)
elif self.value():
prospects_with_status = []
for prospect in queryset:
if prospect.current_status() == self.value():
prospects_with_status.append(prospect.id)
return queryset.filter(id__in=prospects_with_status)
else:
return queryset
class ProspectDeclineReasonFilter(admin.SimpleListFilter):
title = 'Decline reason'
parameter_name = 'reason'
def lookups(self, request, model_admin):
return [(tag.name, tag.value) for tag in DeclinationReason]
def queryset(self, request, queryset):
if self.value():
# Get prospects whose most recent activity has the selected status
return queryset.filter(
activities__declination_reason=self.value()
).annotate(
latest_activity_date=Max('activities__creation_date')
).filter(
activities__creation_date=F('latest_activity_date'),
activities__declination_reason=self.value()
).distinct()
else:
return queryset
class ProspectGroupFilter(admin.SimpleListFilter):
title = 'ProspectGroup'
parameter_name = 'prospect_group'
def lookups(self, request, model_admin):
prospect_groups = ProspectGroup.objects.all().order_by('-creation_date')
return [(group.id, group.name) for group in prospect_groups]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(prospect_groups__id=self.value())
return queryset
class ContactAgainFilter(admin.SimpleListFilter):
title = 'Contact again' # or whatever you want
parameter_name = 'contact_again'
def lookups(self, request, model_admin):
return (
('1', 'Should be contacted'),
# ('0', 'Is null'),
)
def queryset(self, request, queryset):
if self.value() == '1':
return queryset.filter(contact_again__isnull=False)
# if self.value() == '0':
# return queryset.filter(my_field__isnull=True)
return queryset
class PhoneFilter(admin.SimpleListFilter):
title = 'Phone number'
parameter_name = 'phone_filter'
def lookups(self, request, model_admin):
return (
('exclude_mobile', 'Exclude mobile (06/07)'),
('mobile_only', 'Mobile only (06/07)'),
)
def queryset(self, request, queryset):
if self.value() == 'exclude_mobile':
return queryset.exclude(
Q(phone__startswith='06') | Q(phone__startswith='07')
)
elif self.value() == 'mobile_only':
return queryset.filter(
Q(phone__startswith='06') | Q(phone__startswith='07')
)
return queryset

@ -1,61 +0,0 @@
from django import forms
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,103 +0,0 @@
# Generated by Django 5.1 on 2025-07-20 10:20
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Activity',
fields=[
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('status', models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('DECLINED_UNRELATED', 'Declined without significance')], max_length=50, null=True)),
('declination_reason', models.CharField(blank=True, choices=[('TOO_EXPENSIVE', 'Too expensive'), ('USE_OTHER_PRODUCT', 'Use other product'), ('USE_ANDROID', 'Use Android'), ('UNKNOWN', 'Unknown')], max_length=50, null=True)),
('type', models.CharField(blank=True, choices=[('MAIL', 'Mail'), ('SMS', 'SMS'), ('CALL', 'Call'), ('PRESS', 'Press Release'), ('WORD_OF_MOUTH', 'Word of mouth')], 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={
'verbose_name_plural': 'Activities',
'ordering': ['-creation_date'],
},
),
migrations.CreateModel(
name='EmailTemplate',
fields=[
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('name', models.CharField(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='biz.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={
'abstract': False,
},
),
migrations.CreateModel(
name='Entity',
fields=[
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=200, null=True)),
('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='Prospect',
fields=[
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('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='biz.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={
'abstract': False,
},
),
migrations.AddField(
model_name='activity',
name='prospects',
field=models.ManyToManyField(related_name='activities', to='biz.prospect'),
),
]

@ -1,18 +0,0 @@
# Generated by Django 5.1 on 2025-07-31 15:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='prospect',
name='email',
field=models.EmailField(blank=True, max_length=254, null=True, unique=True),
),
]

@ -1,18 +0,0 @@
# Generated by Django 5.1 on 2025-08-07 16:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0002_alter_prospect_email'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='status',
field=models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('NOT_CONCERNED', 'Not concerned'), ('SHOULD_BUY', 'Should buy')], max_length=50, null=True),
),
]

@ -1,18 +0,0 @@
# Generated by Django 5.1 on 2025-09-04 12:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0003_alter_activity_status'),
]
operations = [
migrations.AddField(
model_name='prospect',
name='contact_again',
field=models.DateTimeField(blank=True, null=True),
),
]

@ -1,38 +0,0 @@
# Generated by Django 5.1 on 2025-09-22 12:34
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0004_prospect_contact_again'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='activity',
name='status',
field=models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('NOT_CONCERNED', 'Not concerned'), ('SHOULD_BUY', 'Should buy'), ('HAVE_CREATED_ACCOUNT', 'Have created account')], max_length=50, null=True),
),
migrations.CreateModel(
name='Campaign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('name', models.CharField(blank=True, max_length=200, null=True)),
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('prospects', models.ManyToManyField(blank=True, related_name='campaigns', to='biz.prospect')),
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

@ -1,19 +0,0 @@
# Generated by Django 5.1 on 2025-09-22 13:10
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0005_alter_activity_status_campaign'),
]
operations = [
# migrations.AlterField(
# model_name='campaign',
# name='id',
# field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
# ),
]

@ -1,37 +0,0 @@
# Generated by Django 5.1 on 2025-09-22 14:08
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0006_alter_campaign_id'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProspectGroup',
fields=[
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=200, null=True)),
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('prospects', models.ManyToManyField(blank=True, related_name='prospect_groups', to='biz.prospect')),
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.DeleteModel(
name='Campaign',
),
]

@ -1,23 +0,0 @@
# Generated by Django 5.1 on 2025-10-15 07:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0007_prospectgroup_delete_campaign'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='declination_reason',
field=models.CharField(blank=True, choices=[('TOO_EXPENSIVE', 'Too expensive'), ('USE_OTHER_PRODUCT', 'Use other product'), ('USE_ANDROID', 'Use Android'), ('TOO_FEW_TOURNAMENTS', 'Too few tournaments'), ('NOT_INTERESTED', 'Not interested'), ('UNKNOWN', 'Unknown')], max_length=50, null=True),
),
migrations.AlterField(
model_name='activity',
name='type',
field=models.CharField(blank=True, choices=[('MAIL', 'Mail'), ('SMS', 'SMS'), ('CALL', 'Call'), ('PRESS', 'Press Release'), ('WORD_OF_MOUTH', 'Word of mouth'), ('WHATS_APP', 'WhatsApp')], max_length=20, null=True),
),
]

@ -1,221 +0,0 @@
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 Status(models.TextChoices):
NONE = 'NONE', 'None'
INBOUND = 'INBOUND', 'Inbound'
CONTACTED = 'CONTACTED', 'Contacted'
RESPONDED = 'RESPONDED', 'Responded'
SHOULD_TEST = 'SHOULD_TEST', 'Should test'
TESTING = 'TESTING', 'Testing'
CUSTOMER = 'CUSTOMER', 'Customer'
LOST = 'LOST', 'Lost customer'
DECLINED = 'DECLINED', 'Declined'
# DECLINED_UNRELATED = 'DECLINED_UNRELATED', 'Declined without significance'
NOT_CONCERNED = 'NOT_CONCERNED', 'Not concerned'
SHOULD_BUY = 'SHOULD_BUY', 'Should buy'
HAVE_CREATED_ACCOUNT = 'HAVE_CREATED_ACCOUNT', 'Have created account'
class DeclinationReason(models.TextChoices):
TOO_EXPENSIVE = 'TOO_EXPENSIVE', 'Too expensive'
USE_OTHER_PRODUCT = 'USE_OTHER_PRODUCT', 'Use other product'
USE_ANDROID = 'USE_ANDROID', 'Use Android'
TOO_FEW_TOURNAMENTS = 'TOO_FEW_TOURNAMENTS', 'Too few tournaments'
NOT_INTERESTED = 'NOT_INTERESTED', 'Not interested'
UNKNOWN = 'UNKNOWN', 'Unknown'
class ActivityType(models.TextChoices):
MAIL = 'MAIL', 'Mail'
SMS = 'SMS', 'SMS'
CALL = 'CALL', 'Call'
PRESS = 'PRESS', 'Press Release'
WORD_OF_MOUTH = 'WORD_OF_MOUTH', 'Word of mouth'
WHATS_APP = 'WHATS_APP', 'WhatsApp'
class Entity(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
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):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
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, null=True, blank=True)
phone = models.CharField(max_length=25, null=True, blank=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)
contact_again = models.DateTimeField(null=True, blank=True)
def delete_dependencies(self):
pass
# class Meta:
# permissions = [
# ("manage_prospects", "Can manage prospects"),
# ("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 Status.NONE
def current_activity_type(self):
last_activity = self.activities.exclude(type=None).order_by('-creation_date').first()
if last_activity:
return last_activity.type
return None
def current_text(self):
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first()
if last_activity:
return last_activity.attachment_text
return ''
def current_declination_reason(self):
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first()
if last_activity:
return last_activity.declination_reason
return None
def entity_names(self):
entity_names = [entity.name for entity in self.entities.all()]
return " - ".join(entity_names)
def full_name(self):
if self.first_name and self.last_name:
return f'{self.first_name} {self.last_name}'
elif self.first_name:
return self.first_name
elif self.last_name:
return self.last_name
else:
return 'no name'
def __str__(self):
return self.full_name()
class Activity(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
status = models.CharField(max_length=50, choices=Status.choices, null=True, blank=True)
declination_reason = models.CharField(max_length=50, choices=DeclinationReason.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):
if self.status:
return self.status
elif self.type:
return self.type
else:
return f'desc = {self.description}, attachment_text = {self.attachment_text}'
def delete_dependencies(self):
pass
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# Update last_update for all related prospects when activity is saved
self.prospects.update(last_update=timezone.now())
class Meta:
verbose_name_plural = "Activities"
ordering = ['-creation_date']
# def __str__(self):
# 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.status, self.declination_reason, self.attachment_text, self.description, self.type] 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
def prospect_names(self):
prospect_names = [prospect.full_name() for prospect in self.prospects.all()]
return ", ".join(prospect_names)
@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(),contact_again=None)
class EmailTemplate(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
name = models.CharField(max_length=100)
subject = models.CharField(max_length=200)
body = models.TextField(null=True, blank=True)
activities = models.ManyToManyField(Activity, blank=True, related_name='email_templates')
def __str__(self):
return self.name
def delete_dependencies(self):
pass
class ProspectGroup(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
name = models.CharField(max_length=200, null=True, blank=True)
prospects = models.ManyToManyField(Prospect, blank=True, related_name='prospect_groups')
def user_count(self):
return self.prospects.count()
def __str__(self):
return self.name
def delete_dependencies(self):
pass
# class EmailCampaign(models.Model):
# event = models.OneToOneField(Event, on_delete=models.CASCADE)
# subject = models.CharField(max_length=200)
# 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']

@ -1,448 +0,0 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block extrahead %}
{{ block.super }}
<style>
.dashboard-container {
padding: 20px;
}
.filter-switch {
margin-bottom: 20px;
padding: 15px;
background: #f8f8f8;
border: 1px solid #ddd;
border-radius: 4px;
}
.filter-switch label {
font-weight: bold;
margin-right: 10px;
cursor: pointer;
}
.filter-switch input[type="checkbox"] {
cursor: pointer;
width: 18px;
height: 18px;
vertical-align: middle;
}
.status-section {
margin-bottom: 30px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
.status-header {
background: #417690;
color: white;
padding: 12px 15px;
font-weight: bold;
font-size: 14px;
}
.status-header .status-name {
font-size: 16px;
margin-right: 10px;
}
.status-header .count {
font-size: 13px;
opacity: 0.9;
}
.prospect-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.prospect-table thead {
background: #f9f9f9;
border-bottom: 2px solid #ddd;
}
.prospect-table thead th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: #666;
border-bottom: 1px solid #ddd;
}
.prospect-table th:nth-child(1),
.prospect-table td:nth-child(1) {
width: 225px;
}
.prospect-table th:nth-child(2),
.prospect-table td:nth-child(2) {
width: auto;
}
.prospect-table th:nth-child(3),
.prospect-table td:nth-child(3) {
width: 120px;
}
.prospect-table th:nth-child(4),
.prospect-table td:nth-child(4) {
width: 140px;
}
.prospect-table th:nth-child(5),
.prospect-table td:nth-child(5) {
width: 130px;
}
.prospect-table th:nth-child(6),
.prospect-table td:nth-child(6) {
width: 130px;
}
.prospect-table th.actions-col,
.prospect-table td.actions-col {
width: 80px;
text-align: center;
}
.add-activity-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: #70bf2b;
color: white !important;
text-decoration: none !important;
border-radius: 50%;
font-size: 18px;
font-weight: bold;
transition: background-color 0.2s;
}
.add-activity-btn:hover {
background: #5fa624;
color: white !important;
text-decoration: none !important;
}
.prospect-table tbody tr {
border-bottom: 1px solid #eee;
transition: background-color 0.2s;
}
.prospect-table tbody tr:hover {
background: #f5f5f5;
}
.prospect-table tbody td {
padding: 10px 12px;
font-size: 13px;
}
.prospect-table tbody td a {
color: #417690;
text-decoration: none;
}
.prospect-table tbody td a:hover {
text-decoration: underline;
}
.prospect-name {
font-weight: 500;
}
.prospect-entity {
color: #666;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
}
.prospect-date {
color: #666;
white-space: nowrap;
}
.prospect-status {
display: inline-block;
padding: 3px 8px;
background: #e8f4f8;
border-radius: 3px;
font-size: 11px;
color: #417690;
font-weight: 500;
}
.empty-state {
padding: 40px 15px;
text-align: center;
color: #999;
font-style: italic;
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard-container">
<!-- Quick Actions -->
<div class="quick-actions" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 12px; padding: 25px; margin-bottom: 20px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 15px;">
<a href="{% url 'admin:biz_prospect_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Prospects
</a>
<a href="{% url 'admin:biz_activity_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Activities
</a>
<a href="{% url 'admin:biz_entity_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Entities
</a>
</div>
</div>
<div class="filter-switch">
<label for="my-prospects-toggle">
<input type="checkbox" id="my-prospects-toggle" {% if filter_my %}checked{% endif %}>
Show only my prospects
</label>
</div>
<!-- CONTACT AGAIN Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">CONTACT AGAIN</span>
<span class="count">({{ contact_again_prospects.count }})</span>
</div>
{% if contact_again_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Status</th>
<th>Contact Again</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in contact_again_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td><span class="prospect-status">{{ prospect.current_status }}</span></td>
<td class="prospect-date">{{ prospect.contact_again|date:"d/m/Y" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- SHOULD_TEST Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">SHOULD TEST</span>
<span class="count">({{ should_test_prospects.count }})</span>
</div>
{% if should_test_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in should_test_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- TESTING Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">TESTING</span>
<span class="count">({{ testing_prospects.count }})</span>
</div>
{% if testing_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in testing_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- OTHERS Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">OTHERS</span>
<span class="count">({{ others_prospects.count }})</span>
</div>
{% if others_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Status</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in others_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td><span class="prospect-status">{{ prospect.current_status }}</span></td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- RESPONDED Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">RESPONDED</span>
<span class="count">({{ responded_prospects.count }})</span>
</div>
{% if responded_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in responded_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
</div>
<script>
document.getElementById('my-prospects-toggle').addEventListener('change', function(e) {
const url = new URL(window.location);
if (e.target.checked) {
url.searchParams.set('my', 'true');
} else {
url.searchParams.delete('my');
}
window.location.href = url.toString();
});
</script>
{% endblock %}

@ -1,81 +0,0 @@
{% 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 %}

@ -1,11 +0,0 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
{{ block.super }}
<li>
<a href="{% url 'admin:biz_dashboard' %}" class="viewlink" style="margin-right: 5px;">Dashboard</a>
<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="deletelink" style="margin-right: 5px;">Reset</a>-->
</li>
{% endblock %}

@ -1,53 +0,0 @@
<!-- 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 %}

@ -1,29 +0,0 @@
{% 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 %}

@ -1,58 +0,0 @@
{% extends "biz/base.html" %}
{% block content %}
<div class="container padding-bottom">
<div class="grid-x padding-bottom">
<div class="cell medium-6 large-6 padding10 bubble">
<h1 class="title">Add New Prospect</h1>
<form method="post">
{% csrf_token %}
<div class="form-group">
<label for="entity_name">Entité (nom de club...):</label>
<input type="text" name="entity_name" id="entity_name" />
</div>
<div class="form-group">
<label for="first_name">Prénom:</label>
<input type="text" name="first_name" id="first_name" />
</div>
<div class="form-group">
<label for="last_name">Nom:</label>
<input type="text" name="last_name" id="last_name" />
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" name="email" id="email" required />
</div>
<div class="form-group">
<label for="phone">Téléphone:</label>
<input type="text" name="phone" id="phone" />
</div>
<div class="form-group">
<label for="address">Adresse:</label>
<input type="text" name="address" id="address" />
</div>
<div class="form-group">
<label for="zip_code">Code postal:</label>
<input type="text" name="zip_code" id="zip_code" />
</div>
<div class="form-group">
<label for="city">Ville:</label>
<input type="text" name="city" id="city" />
</div>
<button type="submit" class="small-button margin-v20">
Save Prospect
</button>
</form>
</div>
</div>
</div>
{% endblock %}

@ -1,34 +0,0 @@
{% extends "biz/base.html" %}
{% block content %}
<div class="container mt-4">
<h2>Import Prospects from CSV</h2>
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
{{ form.as_p }}
</div>
<div class="alert alert-info">
<h5>CSV Format Requirements:</h5>
<p>The CSV file should contain the following columns in order:</p>
<ul>
<li>Column 1: Club Code</li>
<li>Column 2: Last Name</li>
<li>Column 3: First Name</li>
<li>Column 4: Email</li>
<li>Column 9: ZIP Code</li>
<li>Column 10: City</li>
</ul>
</div>
<button type="submit" class="btn btn-primary">Import CSV</button>
</form>
</div>
</div>
</div>
{% endblock %}

@ -1,17 +0,0 @@
{% extends "biz/base.html" %}
{% block head_title %}{{ first_title }}{% endblock %}
{% block first_title %}{{ first_title }}{% endblock %}
{% block second_title %}{{ second_title }}{% endblock %}
{% block content %}
<div class="container padding-bottom bubble"><form method="post">
{% csrf_token %}
{{ form.as_p }}
<button class="small-button" type="submit">
{% if is_edit %}Update{% else %}Add{% endif %} Prospect
</button>
</form>
{% endblock %}

@ -1,81 +0,0 @@
{% extends "biz/base.html" %}
{% load static %}
{% block content %}
<div class="container bubble">
<h2>Prospects</h2>
<div class="">
<div class="">
<form method="get" class="filter-form">
{% for field in filter.form %}
<div class="filter-group">
<label class="filter-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
</div>
{% endfor %}
<div class="filter-buttons">
<button type="submit" class="btn btn-primary">Filter</button>
<a href="{% url 'biz:prospect-list' %}" class="btn btn-secondary">Clear</a>
</div>
</form>
</div>
</div>
<!-- <div class="mb-3">
<a href="{% url 'biz:csv-import' %}" class="btn btn-success">Import CSV</a>
<a href="{% url 'biz:send-bulk-email' %}" class="btn btn-primary">Send Email</a>
</div> -->
<span>{{ filter.qs|length }} résultats</span>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th>Entité</th>
<th>Prénom</th>
<th>Nom</th>
<th>Email</th>
<th>Ville</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in filter.qs %}
<tr>
<td><input type="checkbox" name="selected_prospects" value="{{ prospect.id }}"></td>
<td>{{ prospect.entity_name }}</td>
<td>{{ prospect.first_name }}</td>
<td>{{ prospect.last_name }}</td>
<td><a href="mailto:{{ prospect.email }}">{{ prospect.email }}</a></td>
<td>{{ prospect.city }} ({{ prospect.zip_code }})</td>
<td>
{% for status in prospect.prospectstatus_set.all %}
<span class="badge bg-primary">{{ status.status.name }}</span>
{% endfor %}
</td>
<td>
<a href="{% url 'biz:edit-prospect' prospect.id %}">
<button class="btn btn-sm btn-secondary">Edit</button>
</a>
<a href="{% url 'biz:add-event-for-prospect' prospect.id %}">
<button class="btn btn-sm btn-secondary">+ Event</button>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'biz/js/prospects.js' %}"></script>
{% endblock %}

@ -1,7 +0,0 @@
from django import template
register = template.Library()
@register.filter(name='is_biz_manager')
def is_biz_manager(user):
return user.groups.filter(name='biz Manager').exists()

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

@ -1,284 +0,0 @@
# views.py
from django.views.generic import FormView, ListView, DetailView, CreateView, UpdateView
from django.views.generic.edit import FormView, BaseUpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.decorators import permission_required
from django.contrib import messages
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse_lazy
from django.http import HttpResponse, HttpResponseRedirect
from django.views import View
from django.utils import timezone
from django.contrib.sites.shortcuts import get_current_site
from django.template.loader import render_to_string
from django.core.mail import send_mail
from django.conf import settings
from django.db import IntegrityError
from .models import Event, Prospect, ActivityType
from .filters import ProspectFilter
from .forms import CSVImportForm
from .mixins import bizAccessMixin
import csv
from io import TextIOWrapper
from datetime import datetime
# @permission_required('biz.view_biz', 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('biz: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, 'biz/prospect_form.html', context)
# # @permission_required('biz.view_biz', 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('biz: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, 'biz/add_prospect.html')
# class EventCreateView(bizAccessMixin, CreateView):
# model = Event
# form_class = EventForm
# template_name = 'biz/event_form.html'
# success_url = reverse_lazy('biz: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(bizAccessMixin, UpdateView):
# model = Event
# form_class = EventForm
# template_name = 'biz/event_form.html'
# success_url = reverse_lazy('biz: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(bizAccessMixin, 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('biz:setup_email_campaign', kwargs={'event_id': event.id})
# )
# elif event.type == 'SMS':
# return HttpResponseRedirect(
# reverse_lazy('biz:setup_sms_campaign', kwargs={'event_id': event.id})
# )
# elif event.type == 'PRESS':
# return HttpResponseRedirect(
# reverse_lazy('biz:setup_press_release', kwargs={'event_id': event.id})
# )
# messages.success(request, 'Event started successfully!')
# return HttpResponseRedirect(reverse_lazy('biz:planned_events'))
# class EventListView(bizAccessMixin, ListView):
# model = Event
# template_name = 'biz/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(bizAccessMixin, ListView):
# model = Prospect
# template_name = 'biz/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(bizAccessMixin, FormView):
# template_name = 'biz/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(bizAccessMixin, FormView):
# template_name = 'biz/send_bulk_email.html'
# form_class = BulkEmailForm
# success_url = reverse_lazy('biz: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)

@ -0,0 +1 @@
This is a django customer relationship managemement (CRM) app.

@ -0,0 +1,96 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import (
Prospect,
Status,
ProspectStatus,
Event,
EmailCampaign,
EmailTracker
)
@admin.register(Prospect)
class ProspectAdmin(admin.ModelAdmin):
list_display = ('name', 'email', 'region', 'created_at')
list_filter = ('region', 'created_at')
search_fields = ('name', 'email', 'region')
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'
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(
'<span style="color: green;">✓</span> {}',
date.strftime('%Y-%m-%d %H:%M') if date else ''
)
return format_html('<span style="color: red;">✗</span>')

@ -1,5 +1,5 @@
from django.apps import AppConfig
class BizConfig(AppConfig):
class CrmConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'biz'
name = 'crm'

@ -0,0 +1,18 @@
import django_filters
from .models import Event, Status, Prospect
class ProspectFilter(django_filters.FilterSet):
region = django_filters.CharFilter(lookup_expr='icontains')
events = django_filters.ModelMultipleChoiceFilter(
queryset=Event.objects.all(),
field_name='events',
)
statuses = django_filters.ModelMultipleChoiceFilter(
queryset=Status.objects.all(),
field_name='prospectstatus__status',
)
class Meta:
model = Prospect
fields = ['region', 'events', 'statuses']

@ -0,0 +1,40 @@
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 CSVImportForm(forms.Form):
csv_file = forms.FileField()
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'}),
}

@ -0,0 +1,94 @@
# Generated by Django 4.2.11 on 2024-12-08 15:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Prospect',
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')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Event',
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')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='EmailCampaign',
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')),
],
),
migrations.CreateModel(
name='EmailTracker',
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')),
],
options={
'unique_together': {('campaign', 'prospect')},
},
),
]

@ -0,0 +1,60 @@
# 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,6 +1,6 @@
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
from django.core.exceptions import PermissionDenied
class bizAccessMixin(LoginRequiredMixin, UserPassesTestMixin):
class CRMAccessMixin(LoginRequiredMixin, UserPassesTestMixin):
def test_func(self):
return self.request.user.groups.filter(name='biz Manager').exists()
return self.request.user.groups.filter(name='CRM Manager').exists()

@ -0,0 +1,112 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
import uuid
User = get_user_model()
class EventType(models.TextChoices):
MAILING = 'MAIL', 'Mailing List'
SMS = 'SMS', 'SMS Campaign'
PRESS = 'PRESS', 'Press Release'
class Prospect(models.Model):
email = models.EmailField(unique=True)
name = models.CharField(max_length=200)
region = models.CharField(max_length=100)
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)
class Meta:
permissions = [
("manage_prospects", "Can manage prospects"),
("view_prospects", "Can view prospects"),
]
def __str__(self):
return f"{self.name} ({self.email})"
class Status(models.Model):
name = models.CharField(max_length=100, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
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)
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']
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)
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,10 @@
document.addEventListener("DOMContentLoaded", function () {
const selectAll = document.getElementById("select-all");
const prospectCheckboxes = document.getElementsByName("selected_prospects");
selectAll.addEventListener("change", function () {
prospectCheckboxes.forEach((checkbox) => {
checkbox.checked = selectAll.checked;
});
});
});

@ -0,0 +1,31 @@
{% extends "crm/base.html" %} {% block content %}
<div class="container padding-bottom">
<div class="grid-x padding-bottom">
<div class="cell medium-6 large-6 my-block bubble">
<h1 class="title">Add New Prospect</h1>
<form method="post">
{% csrf_token %}
<div class="form-group">
<label for="name">Name:</label>
<input type="text" name="name" id="name" required />
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" name="email" id="email" required />
</div>
<div class="form-group">
<label for="region">Region:</label>
<input type="text" name="region" id="region" required />
</div>
<button type="submit" class="small-button margin-v20">
Save Prospect
</button>
</form>
</div>
</div>
</div>
{% endblock %}

@ -37,14 +37,12 @@
</script>
<!-- End Matomo Code -->
{% block extra_js %}{% endblock %}
</head>
<body class="wrapper">
<header>
<div class="grid-x">
<div class="medium-6 large-9 cell topblock padding10 ">
<div class="medium-6 large-9 cell topblock my-block ">
<a href="{% url 'index' %}">
<img
src="{% static 'tournaments/images/PadelClub_logo_512.png' %}"

@ -1,7 +1,7 @@
{% extends "biz/base.html" %} {% block content %}
{% extends "crm/base.html" %} {% block content %}
<div class="container">
<div class="grid-x padding-bottom">
<div class="cell medium-6 large-6 padding10 bubble">
<div class="cell medium-6 large-6 my-block bubble">
<h1 class="title">
{% if form.instance.pk %}Edit{% else %}Add{% endif %} Event
</h1>
@ -14,7 +14,7 @@
Save Event
</button>
<a
href="{% url 'biz:planned_events' %}"
href="{% url 'crm:planned_events' %}"
class="btn btn-secondary"
>Cancel</a
>

@ -7,9 +7,9 @@
<div class="right-column">
<span>{{ event.date|date:"d/m/Y H:i" }}</span>
<a href="{% url 'biz:edit_event' event.id %}" class="small-button">Edit</a>
<a href="{% url 'crm:edit_event' event.id %}" class="small-button">Edit</a>
<!-- {% if event.status == 'PLANNED' %}
<a href="{% url 'biz:start_event' event.id %}" class="small-button">Start</a>
<a href="{% url 'crm:start_event' event.id %}" class="small-button">Start</a>
{% endif %} -->
</div>
</div>

@ -1,29 +1,22 @@
{% extends "biz/base.html" %}
{% load biz_tags %}
{% extends "crm/base.html" %}
{% load crm_tags %}
{% block content %}
{% if request.user|is_biz_manager %}
{% if request.user|is_crm_manager %}
<div class="d-flex">
<a href="{% url 'biz:prospect-list' %}" class="small-button margin-v20">
Prospects
</a>
<a href="{% url 'biz:add-event' %}" class="small-button margin-v20">
Ajouter un évènement
</a>
<a href="{% url 'biz:add-prospect' %}" class="small-button margin-v20 left-margin">
Ajouter un prospect
<a href="{% url 'crm:add_event' %}" class="small-button margin-v20">
Add Event
</a>
<a href="{% url 'biz:csv-import' %}" class="small-button margin-v20 left-margin">
Import
<a href="{% url 'crm:add_prospect' %}" class="small-button margin-v20 left-margin">
Add Prospect
</a>
</div>
<div class="container grid-x padding-bottom">
<div class="cell medium-6 large-6 padding10 bubble">
<div class="cell medium-6 large-6 my-block bubble">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="title">Completed Events</h1>
@ -31,7 +24,7 @@
<div class="list-group">
{% for event in completed_events %}
{% include "biz/event_row.html" with event=event %}
{% include "crm/event_row.html" with event=event %}
{% empty %}
<div class="list-group-item">No completed events.</div>
{% endfor %}
@ -39,7 +32,7 @@
</div>
<div class="cell medium-6 large-6 padding10 bubble">
<div class="cell medium-6 large-6 my-block bubble">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="title">Planned Events</h1>
@ -47,7 +40,7 @@
<div class="list-group">
{% for event in planned_events %}
{% include "biz/event_row.html" with event=event %}
{% include "crm/event_row.html" with event=event %}
{% empty %}
<div class="list-group-item">No planned events.</div>
{% endfor %}

@ -0,0 +1,57 @@
{% extends "crm/base.html" %}
{% block content %}
<div class="container bubble">
<h2>Prospects</h2>
<div class="card mb-4">
<div class="card-body">
<form method="get" class="row g-3">
{{ filter.form }}
<div class="col-12">
<button type="submit" class="btn btn-primary">Filter</button>
<a href="{% url 'crm:prospect-list' %}" class="btn btn-secondary">Clear</a>
</div>
</form>
</div>
</div>
<div class="mb-3">
<a href="{% url 'crm:prospect-import' %}" class="btn btn-success">Import CSV</a>
<a href="{% url 'crm:send-bulk-email' %}" class="btn btn-primary">Send Email</a>
</div>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th>Name</th>
<th>Email</th>
<th>Region</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in prospects %}
<tr>
<td><input type="checkbox" name="selected_prospects" value="{{ prospect.id }}"></td>
<td>{{ prospect.name }}</td>
<td>{{ prospect.email }}</td>
<td>{{ prospect.region }}</td>
<td>
{% for status in prospect.prospectstatus_set.all %}
<span class="badge bg-primary">{{ status.status.name }}</span>
{% endfor %}
</td>
<td>
<button class="btn btn-sm btn-secondary">Edit</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

@ -1,4 +1,4 @@
{% extends "biz/base.html" %}
{% extends "crm/base.html" %}
{% block content %}
<div class="container mt-4">
@ -41,7 +41,7 @@
</div>
<button type="submit" class="btn btn-primary">Send Email</button>
<a href="{% url 'biz:prospect-list' %}" class="btn btn-secondary">Cancel</a>
<a href="{% url 'crm:prospect-list' %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}

@ -0,0 +1,7 @@
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()

@ -1,17 +1,15 @@
from django.urls import path
from . import views
app_name = 'biz'
app_name = 'crm'
urlpatterns = [
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='events'),
path('events/add/', views.EventCreateView.as_view(), name='add-event'),
path('events/add/<int:prospect_id>/', views.EventCreateView.as_view(), name='add-event-for-prospect'),
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='planned-events'),
path('events/add/', views.EventCreateView.as_view(), name='add_event'),
path('events/<int:pk>/edit/', views.EditEventView.as_view(), name='edit_event'),
path('events/<int:pk>/start/', views.StartEventView.as_view(), name='start_event'),
path('prospects/', views.ProspectListView.as_view(), name='prospect-list'),
path('prospect/add/', views.prospect_form, name='add-prospect'),
path('prospect/<int:pk>/edit/', views.prospect_form, name='edit-prospect'),
path('prospects/import/', views.CSVImportView.as_view(), name='csv-import'),
path('add-prospect/', views.add_prospect, name='add_prospect'),
path('prospects/import/', views.CSVImportView.as_view(), name='prospect-import'),
path('email/send/', views.SendBulkEmailView.as_view(), name='send-bulk-email'),
]

@ -0,0 +1,186 @@
# views.py
from django.views.generic import FormView, ListView, DetailView, CreateView, UpdateView
from django.views.generic.edit import FormView, BaseUpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.decorators import permission_required
from django.contrib import messages
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse_lazy
from django.http import HttpResponse, HttpResponseRedirect
from django.views import View
from django.utils import timezone
from django.contrib.sites.shortcuts import get_current_site
from django.template.loader import render_to_string
from django.core.mail import send_mail
from django.conf import settings
from .models import Event, Prospect, EmailTracker, EmailCampaign, EventType
from .filters import ProspectFilter
from .forms import CSVImportForm, BulkEmailForm, EventForm
from .mixins import CRMAccessMixin
import csv
from io import TextIOWrapper
from datetime import datetime
@permission_required('crm.view_crm', raise_exception=True)
def add_prospect(request):
if request.method == 'POST':
name = request.POST.get('name')
email = request.POST.get('email')
region = request.POST.get('region')
try:
prospect = Prospect.objects.create(
name=name,
email=email,
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 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_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'
)
reader = csv.DictReader(csv_file)
for row in reader:
Prospect.objects.create(
email=row['email'],
name=row.get('name', ''),
region=row.get('region', ''),
created_by=self.request.user,
modified_by=self.request.user
)
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)

@ -9,30 +9,8 @@ https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
import os
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "padelclub_backend.settings")
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'padelclub_backend.settings')
django_asgi_app = get_asgi_application()
from sync.routing import websocket_urlpatterns
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"websocket": AllowedHostsOriginValidator(
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
),
}
)
# import os
# from django.core.asgi import get_asgi_application
# os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'padelclub_backend.settings')
# application = get_asgi_application()
application = get_asgi_application()

@ -16,6 +16,7 @@ import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
@ -31,13 +32,8 @@ ALLOWED_HOSTS = ['*']
# Application definition
INSTALLED_APPS = [
'daphne',
'authentication',
'sync',
'tournaments',
'shop',
'biz',
'api',
# 'crm',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@ -48,11 +44,7 @@ INSTALLED_APPS = [
'rest_framework.authtoken',
'dj_rest_auth',
'qr_code',
'channels_redis',
'django_filters',
'background_task',
'rest_framework_api_key',
# 'django_filters',
]
AUTH_USER_MODEL = "tournaments.CustomUser"
@ -72,7 +64,7 @@ ROOT_URLCONF = 'padelclub_backend.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')], # Project-level templates
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -80,8 +72,6 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'shop.context_processors.stripe_context',
],
},
},
@ -89,7 +79,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'padelclub_backend.wsgi.application'
ASGI_APPLICATION = "padelclub_backend.asgi.application"
# Database
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
@ -104,6 +93,8 @@ DATABASES = {
}
}
# Password validation
# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators
@ -146,10 +137,6 @@ USE_L10N = True
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static/')
# Media files (User uploads)
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')
# Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
@ -165,82 +152,6 @@ AUTHENTICATION_BACKENDS = [
]
CSRF_COOKIE_SECURE = True # if using HTTPS
SESSION_COOKIE_SECURE = True
LOGS_DIR = os.path.join(BASE_DIR, 'logs')
os.makedirs(LOGS_DIR, exist_ok=True)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
'style': '{',
},
'simple': {
'format': '{levelname} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': os.path.join(BASE_DIR, 'django.log'),
'formatter': 'verbose',
},
'rotating_file': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOGS_DIR, 'django.log'),
'maxBytes': 10 * 1024 * 1024,
'backupCount': 10,
'formatter': 'verbose',
},
'mail_admins': {
'level': 'ERROR',
'class': 'django.utils.log.AdminEmailHandler'
},
},
'loggers': {
'django.request': {
'handlers': ['mail_admins'],
'level': 'ERROR',
'propagate': True,
},
'django': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': True,
},
'tournaments': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
},
'authentication': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
},
'sync': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
},
'api': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
},
},
}
from .settings_app import *
from .settings_local import *
from .settings_app import *

@ -1,54 +1,43 @@
# Rest Framework configuration
REST_FRAMEWORK = {
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S.%f%z",
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.BasicAuthentication",
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
]
}
EMAIL_HOST_USER = "automatic@padelclub.app"
EMAIL_HOST_PASSWORD = "XLR@Sport@2024"
DEFAULT_FROM_EMAIL = "Padel Club <automatic@padelclub.app>"
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp-xlr.alwaysdata.net"
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# EMAIL_HOST = 'smtp-xlr.alwaysdata.net'
# EMAIL_PORT = 587
EMAIL_HOST_USER = 'automatic@padelclub.app'
EMAIL_HOST_PASSWORD = 'XLR@Sport@2024'
# EMAIL_USE_TLS = True
DEFAULT_FROM_EMAIL = 'Padel Club <automatic@padelclub.app>'
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp-xlr.alwaysdata.net'
EMAIL_PORT = 587
#EMAIL_HOST_USER = 'xlr@alwaysdata.net'
#EMAIL_HOST_PASSWORD = 'XLRSport$2024'
EMAIL_USE_TLS = True
#DEFAULT_FROM_EMAIL = 'Padel Club <xlr@alwaysdata.net>'
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
},
"qr-code": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "qr-code-cache",
"TIMEOUT": 3600,
},
}
QR_CODE_CACHE_ALIAS = "qr-code"
SYNC_APPS = {
"sync": {},
"tournaments": {"exclude": ["Log", "FailedApiCall", "DeviceToken", "Image"]},
# 'biz': {},
}
SYNC_MODEL_CHILDREN_SHARING = {
"Match": ["team_scores", "team_registration", "player_registrations"]
'qr-code': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'qr-code-cache',
'TIMEOUT': 3600
}
}
STRIPE_CURRENCY = "eur"
# Add managers who should receive internal emails
SHOP_MANAGERS = [
("Shop Admin", "shop-admin@padelclub.app"),
# ('Laurent Morvillier', 'laurent@padelclub.app'),
]
SHOP_SITE_ROOT_URL = "https://padelclub.app"
SHOP_SUPPORT_EMAIL = "shop@padelclub.app"
QR_CODE_CACHE_ALIAS = 'qr-code'

@ -9,8 +9,6 @@ DEBUG = True
ALLOWED_HOSTS = []
CSRF_TRUSTED_ORIGINS = [] # put same than above
SITE_NAME = 'local'
#ADMINS = [('Laurent', 'laurent@padelclub.app'), ('Razmig', 'razmig@padelclub.app')]
@ -19,52 +17,5 @@ DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'ATOMIC_REQUESTS': True,
}
}
# CHANNEL_LAYERS = {
# "default": {
# "BACKEND": "channels.layers.InMemoryChannelLayer"
# }
# }
# CHANNEL_LAYERS = {
# "default": {
# "BACKEND": "channels_redis.core.RedisChannelLayer",
# "CONFIG": {
# "hosts": [("localhost", 8300)],
# },
# },
# }
STRIPE_MODE = 'test'
STRIPE_PUBLISHABLE_KEY = ''
STRIPE_SECRET_KEY = ''
SHOP_STRIPE_WEBHOOK_SECRET = 'whsec_...' # Your existing webhook secret
TOURNAMENT_STRIPE_WEBHOOK_SECRET = 'whsec_...' # New webhook secret for tournaments
XLR_STRIPE_WEBHOOK_SECRET = 'whsec_...' # New webhook secret for padel club
STRIPE_FEE = 0.0075
TOURNAMENT_SETTINGS = {
'TIME_PROXIMITY_RULES': {
24: 30, # within 24h → 30 min
48: 60, # within 48h → 60 min
72: 120, # within 72h → 120 min
'default': 240
},
'WAITING_LIST_RULES': {
30: 30, # 30+ teams → 30 min
20: 60, # 20+ teams → 60 min
10: 120, # 10+ teams → 120 min
'default': 240
},
'BUSINESS_RULES': {
'hours': {
'start': 8, # 8:00
'end': 21, # 21:00
}
},
'MINIMUM_RESPONSE_TIME': 30, # requires to be like the BACKGROUND_SCHEDULED_TASK_INTERVAL
}
BACKGROUND_SCHEDULED_TASK_INTERVAL = 30 # minutes
LIVE_TESTING = False

@ -15,42 +15,16 @@ Including another URLconf
"""
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, gather_monthly_tournaments_and_umpires
urlpatterns = [
# path('roads/', include(router.urls)),
path("", include("tournaments.urls")),
path('shop/', include('shop.urls')),
# path("crm/", include("crm.urls")),
path('roads/', include("api.urls")),
path('kingdom/debug/', debug_tools_page, name='debug_tools'),
path('kingdom/debug/enrich-rankings-with-licenses/', enrich_rankings_with_licenses, name='enrich_rankings_with_licenses'),
path('kingdom/debug/search-player-by-name/', search_player_by_name, name='search_player_by_name'),
path('kingdom/debug/download-french-padel-rankings/', download_french_padel_rankings, name='download_french_padel_rankings'),
path('kingdom/debug/test-player-apis/', test_player_details_apis, name='test_player_apis'),
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/biz/', include('biz.admin_urls')),
path('kingdom/', admin.site.urls),
path('api-auth/', include('rest_framework.urls')),
path('dj-auth/', include('django.contrib.auth.urls')),
path(
"kingdom/debug/gather-monthly-umpires/",
gather_monthly_tournaments_and_umpires,
name="gather_monthly_umpires",
),
]
def email_users_view(request):
return render(request, 'admin/crm/email_users.html', {
'title': 'Email Users',
})
#path('dj-auth/', include('django.contrib.auth.urls')),
# Serve media files in development
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
]

@ -1,22 +1,14 @@
Django==5.1
Django==4.2.11
djangorestframework==3.14.0
psycopg2-binary==2.9.9
dj-rest-auth==6.0.0
dj-rest-auth==5.1.0
django-qr-code==4.0.1
pycryptodome==3.20.0
requests==2.31.0
PyJWT==2.8.0
httpx[http2]==0.27.0
channels[daphne]==4.1.0
twisted[http2,tls]==24.11.0
channels-redis==4.2.1
pandas==2.2.2
xlrd==2.0.1
openpyxl==3.1.5
django-filter==24.3
cryptography==41.0.7
stripe==11.6.0
django-background-tasks==1.2.8
Pillow==10.2.0
playwright==1.40.0
djangorestframework-api-key==3.1.0

@ -1,11 +0,0 @@
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 head commit

@ -1,414 +0,0 @@
from django.contrib import admin
from django.shortcuts import render, redirect
from django.utils.html import format_html
from django.urls import path
from django.http import HttpResponseRedirect
from django import forms
from django.db.models import Sum, Count, Avg
from datetime import datetime, timedelta
from django.utils import timezone
from .models import (
Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage,
OrderStatus, ShippingAddress
)
class ShopAdminSite(admin.AdminSite):
site_header = "Shop Administration"
site_title = "Shop Admin Portal"
index_title = "Welcome to Shop Administration"
def index(self, request, extra_context=None):
"""Custom admin index view with dashboard"""
# Calculate order statistics
order_status_data = []
total_orders = Order.objects.count()
total_revenue = Order.objects.aggregate(Sum('total_price'))['total_price__sum'] or 0
# Get data for each status
for status_choice in OrderStatus.choices:
status_code, status_label = status_choice
orders_for_status = Order.objects.filter(status=status_code)
count = orders_for_status.count()
total_amount = orders_for_status.aggregate(Sum('total_price'))['total_price__sum'] or 0
avg_order_value = orders_for_status.aggregate(Avg('total_price'))['total_price__avg'] or 0
percentage = (count / total_orders * 100) if total_orders > 0 else 0
order_status_data.append({
'status': status_code,
'label': status_label,
'count': count,
'total_amount': total_amount,
'avg_order_value': avg_order_value,
'percentage': percentage
})
# Recent activity calculations
now = timezone.now()
today = now.date()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
orders_today = Order.objects.filter(date_ordered__date=today).count()
orders_this_week = Order.objects.filter(date_ordered__date__gte=week_ago).count()
orders_this_month = Order.objects.filter(date_ordered__date__gte=month_ago).count()
orders_to_prepare = Order.objects.filter(status=OrderStatus.PAID).count()
extra_context = extra_context or {}
extra_context.update({
'order_status_data': order_status_data,
'total_orders': total_orders,
'total_revenue': total_revenue,
'orders_today': orders_today,
'orders_this_week': orders_this_week,
'orders_this_month': orders_this_month,
'orders_to_prepare': orders_to_prepare,
})
return render(request, 'admin/shop/dashboard.html', extra_context)
# Create an instance of the custom admin site
shop_admin_site = ShopAdminSite(name='shop_admin')
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("title", "ordering_value", "price", "cut")
search_fields = ["title", "description"] # Enable search for autocomplete
@admin.register(Color)
class ColorAdmin(admin.ModelAdmin):
list_display = ("color_preview", "name", "ordering", "colorHex", "secondary_hex_color")
list_editable = ("ordering",)
ordering = ["ordering"]
search_fields = ["name"]
list_per_page = 20
def color_preview(self, obj):
if obj.secondary_hex_color:
return format_html(
'<div style="background-image: linear-gradient(to right, {} 50%, {} 50%); '
'width: 60px; height: 30px; border-radius: 15px; border: 1px solid #ddd;"></div>',
obj.colorHex, obj.secondary_hex_color
)
return format_html(
'<div style="background-color: {}; width: 60px; height: 30px; '
'border-radius: 15px; border: 1px solid #ddd;"></div>',
obj.colorHex
)
@admin.register(Size)
class SizeAdmin(admin.ModelAdmin):
list_display = ("name",)
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 1 # Show one extra row for adding new items
autocomplete_fields = ['product'] # Enable product search
fields = ('product', 'quantity', 'color', 'size', 'price')
@admin.register(OrderItem)
class OrderItemAdmin(admin.ModelAdmin):
list_display = ('order', 'product', 'quantity', 'color', 'size', 'price', 'get_total_price')
list_filter = ('product', 'color', 'size', 'order__status')
search_fields = ('order__id', 'product__title', 'order__user__email', 'order__guest_user__email')
autocomplete_fields = ['order', 'product']
list_editable = ('quantity', 'price')
def get_total_price(self, obj):
return obj.get_total_price()
get_total_price.short_description = 'Total Price'
get_total_price.admin_order_field = 'price' # Allows column to be sortable
@admin.register(ShippingAddress)
class ShippingAddressAdmin(admin.ModelAdmin):
list_display = ('street_address', 'city', 'postal_code', 'country')
search_fields = ('street_address', 'city', 'postal_code', 'country')
class ChangeOrderStatusForm(forms.Form):
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
status = forms.ChoiceField(choices=OrderStatus.choices, label="New Status")
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('id', 'get_email', 'date_ordered', 'status', 'total_price', 'get_shipping_address')
inlines = [OrderItemInline]
list_filter = ('status', 'payment_status')
readonly_fields = ('shipping_address_details',)
actions = ['change_order_status']
autocomplete_fields = ['user'] # Add this line for user search functionality
search_fields = ['id', 'user__email', 'user__username', 'guest_user__email'] # Add this line
def get_email(self, obj):
if obj.guest_user:
return obj.guest_user.email
else:
return obj.user.email
get_email.short_description = 'Email'
def get_shipping_address(self, obj):
if obj.shipping_address:
return f"{obj.shipping_address.street_address}, {obj.shipping_address.city}"
return "No shipping address"
get_shipping_address.short_description = 'Shipping Address'
def shipping_address_details(self, obj):
if obj.shipping_address:
return format_html(
"""
<div style="padding: 10px; background-color: #f9f9f9; border-radius: 4px;">
<strong>Street:</strong> {}<br>
{}
<strong>City:</strong> {}<br>
<strong>State:</strong> {}<br>
<strong>Postal Code:</strong> {}<br>
<strong>Country:</strong> {}
</div>
""",
obj.shipping_address.street_address,
f"<strong>Apartment:</strong> {obj.shipping_address.apartment}<br>" if obj.shipping_address.apartment else "",
obj.shipping_address.city,
obj.shipping_address.state,
obj.shipping_address.postal_code,
obj.shipping_address.country,
)
return "No shipping address set"
shipping_address_details.short_description = 'Shipping Address Details'
fieldsets = (
(None, {
'fields': ('user', 'guest_user', 'status', 'payment_status', 'total_price')
}),
('Shipping Information', {
'fields': ('shipping_address_details',),
}),
('Payment Details', {
'fields': ('stripe_payment_intent_id', 'stripe_checkout_session_id', 'stripe_mode'),
'classes': ('collapse',)
}),
('Discount Information', {
'fields': ('coupon', 'discount_amount'),
'classes': ('collapse',)
}),
)
def dashboard_view(self, request):
"""Dashboard view with order statistics"""
# Calculate order statistics
order_status_data = []
total_orders = Order.objects.count()
total_revenue = Order.objects.aggregate(Sum('total_price'))['total_price__sum'] or 0
# Get data for each status
for status_choice in OrderStatus.choices:
status_code, status_label = status_choice
orders_for_status = Order.objects.filter(status=status_code)
count = orders_for_status.count()
total_amount = orders_for_status.aggregate(Sum('total_price'))['total_price__sum'] or 0
avg_order_value = orders_for_status.aggregate(Avg('total_price'))['total_price__avg'] or 0
percentage = (count / total_orders * 100) if total_orders > 0 else 0
order_status_data.append({
'status': status_code,
'label': status_label,
'count': count,
'total_amount': total_amount,
'avg_order_value': avg_order_value,
'percentage': percentage
})
# Recent activity calculations
now = timezone.now()
today = now.date()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
orders_today = Order.objects.filter(date_ordered__date=today).count()
orders_this_week = Order.objects.filter(date_ordered__date__gte=week_ago).count()
orders_this_month = Order.objects.filter(date_ordered__date__gte=month_ago).count()
orders_to_prepare = Order.objects.filter(status=OrderStatus.PAID).count()
context = {
'title': 'Shop Dashboard',
'app_label': 'shop',
'opts': Order._meta,
'order_status_data': order_status_data,
'total_orders': total_orders,
'total_revenue': total_revenue,
'orders_today': orders_today,
'orders_this_week': orders_this_week,
'orders_this_month': orders_this_month,
'orders_to_prepare': orders_to_prepare,
}
return render(request, 'admin/shop/dashboard.html', context)
def changelist_view(self, request, extra_context=None):
# If 'show_preparation' parameter is in the request, show the preparation view
if 'show_preparation' in request.GET:
return self.preparation_view(request)
# Otherwise show the normal change list
extra_context = extra_context or {}
paid_orders_count = Order.objects.filter(status=OrderStatus.PAID).count()
extra_context['paid_orders_count'] = paid_orders_count
return super().changelist_view(request, extra_context=extra_context)
def preparation_view(self, request):
"""View for items that need to be prepared"""
# Get paid orders
orders = Order.objects.filter(status=OrderStatus.PAID).order_by('-date_ordered')
# Group items by product, color, size
items_by_variant = {}
all_items = OrderItem.objects.filter(order__status=OrderStatus.PAID)
for item in all_items:
# Create a key for grouping items
key = (
str(item.product.id),
str(item.color.id) if item.color else 'none',
str(item.size.id) if item.size else 'none'
)
if key not in items_by_variant:
items_by_variant[key] = {
'product': item.product,
'color': item.color,
'size': item.size,
'quantity': 0,
'orders': set()
}
items_by_variant[key]['quantity'] += item.quantity
items_by_variant[key]['orders'].add(item.order.id)
# Convert to list and sort
items_list = list(items_by_variant.values())
items_list.sort(key=lambda x: x['product'].title)
context = {
'title': 'Orders to Prepare',
'app_label': 'shop',
'opts': Order._meta,
'orders': orders,
'items': items_list,
'total_orders': orders.count(),
'total_items': sum(i['quantity'] for i in items_list)
}
return render(
request,
'admin/shop/order/preparation_view.html',
context
)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('dashboard/', self.admin_site.admin_view(self.dashboard_view), name='shop_order_dashboard'),
path('prepare-all/', self.admin_site.admin_view(self.prepare_all_orders), name='prepare_all_orders'),
path('<int:order_id>/prepare/', self.admin_site.admin_view(self.prepare_order), name='prepare_order'),
path('<int:order_id>/cancel-refund/', self.admin_site.admin_view(self.cancel_and_refund_order), name='cancel_and_refund_order'),
]
return custom_urls + urls
def prepare_all_orders(self, request):
if request.method == 'POST':
Order.objects.filter(status=OrderStatus.PAID).update(status=OrderStatus.PREPARED)
self.message_user(request, "All orders have been marked as prepared.")
return redirect('admin:shop_order_changelist')
def prepare_order(self, request, order_id):
if request.method == 'POST':
order = Order.objects.get(id=order_id)
order.status = OrderStatus.PREPARED
order.save()
self.message_user(request, f"Order #{order_id} has been marked as prepared.")
return redirect('admin:shop_order_changelist')
def cancel_and_refund_order(self, request, order_id):
if request.method == 'POST':
order = Order.objects.get(id=order_id)
try:
# Reuse the cancel_order logic from your views
from .views import cancel_order
cancel_order(request, order_id)
self.message_user(request, f"Order #{order_id} has been cancelled and refunded.")
except Exception as e:
self.message_user(request, f"Error cancelling order: {str(e)}", level='ERROR')
return redirect('admin:shop_order_changelist')
def change_order_status(self, request, queryset):
"""Admin action to change the status of selected orders"""
form = None
if 'apply' in request.POST:
form = ChangeOrderStatusForm(request.POST)
if form.is_valid():
status = form.cleaned_data['status']
count = 0
for order in queryset:
order.status = status
order.save()
count += 1
self.message_user(request, f"{count} orders have been updated to status '{OrderStatus(status).label}'.")
return HttpResponseRedirect(request.get_full_path())
if not form:
form = ChangeOrderStatusForm(initial={'_selected_action': request.POST.getlist('_selected_action')})
context = {
'title': 'Change Order Status',
'orders': queryset,
'form': form,
'action': 'change_order_status'
}
return render(request, 'admin/shop/order/change_status.html', context)
change_order_status.short_description = "Change status for selected orders"
class GuestUserOrderInline(admin.TabularInline):
model = Order
extra = 0
readonly_fields = ('date_ordered', 'total_price')
can_delete = False
show_change_link = True
exclude = ('user',) # Exclude the user field from the inline display
@admin.register(GuestUser)
class GuestUserAdmin(admin.ModelAdmin):
list_display = ('email', 'phone')
inlines = [GuestUserOrderInline]
@admin.register(Coupon)
class CouponAdmin(admin.ModelAdmin):
list_display = ('code', 'discount_amount', 'discount_percent', 'is_active',
'valid_from', 'valid_to', 'current_uses', 'max_uses')
list_filter = ('is_active', 'valid_from', 'valid_to')
search_fields = ('code', 'description')
readonly_fields = ('current_uses', 'created_at', 'stripe_coupon_id')
fieldsets = (
('Basic Information', {
'fields': ('code', 'description', 'is_active')
}),
('Discount', {
'fields': ('discount_amount', 'discount_percent')
}),
('Validity', {
'fields': ('valid_from', 'valid_to', 'max_uses', 'current_uses')
}),
('Stripe Information', {
'fields': ('stripe_coupon_id',),
'classes': ('collapse',)
}),
)
@admin.register(CouponUsage)
class CouponUsageAdmin(admin.ModelAdmin):
list_display = ('coupon', 'user', 'guest_email', 'order', 'used_at')
list_filter = ('used_at',)
search_fields = ('coupon__code', 'user__username', 'user__email', 'guest_email')
readonly_fields = ('used_at',)

@ -1,8 +0,0 @@
from django.apps import AppConfig
class ShopConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'shop'
def ready(self):
import shop.signals # Import signals to ensure they're connected

@ -1,94 +0,0 @@
from .models import CartItem, Product, Color, Size
def get_or_create_cart_id(request):
"""Get the cart ID from the session or create a new one"""
if 'cart_id' not in request.session:
request.session['cart_id'] = request.session.session_key or request.session.create()
return request.session['cart_id']
def get_cart_items(request):
"""Get all cart items for the current session"""
cart_id = get_or_create_cart_id(request)
return CartItem.objects.filter(session_id=cart_id)
def add_to_cart(request, product_id, quantity=1, color_id=None, size_id=None):
"""Add a product to the cart or update its quantity"""
product = Product.objects.get(id=product_id)
cart_id = get_or_create_cart_id(request)
color = Color.objects.get(id=color_id) if color_id else None
size = Size.objects.get(id=size_id) if size_id else None
try:
# Try to get existing cart item with the same product, color, and size
cart_item = CartItem.objects.get(
product=product,
session_id=cart_id,
color=color,
size=size
)
cart_item.quantity += quantity
cart_item.save()
except CartItem.DoesNotExist:
# Create new cart item
cart_item = CartItem.objects.create(
product=product,
quantity=quantity,
session_id=cart_id,
color=color,
size=size
)
return cart_item
def remove_from_cart(request, product_id):
"""Remove a product from the cart"""
cart_id = get_or_create_cart_id(request)
CartItem.objects.filter(product_id=product_id, session_id=cart_id).delete()
def update_cart_item(request, product_id, quantity):
"""Update the quantity of a cart item"""
cart_id = get_or_create_cart_id(request)
cart_item = CartItem.objects.get(product_id=product_id, session_id=cart_id)
if quantity > 0:
cart_item.quantity = quantity
cart_item.save()
else:
cart_item.delete()
def get_cart_total(request):
"""Calculate the total price of all items in the cart"""
return sum(item.product.price * item.quantity for item in get_cart_items(request))
def clear_cart(request):
"""Clear the cart"""
cart_id = get_or_create_cart_id(request)
CartItem.objects.filter(session_id=cart_id).delete()
# Add this function to your cart.py file
def get_cart_item(request, item_id):
"""Get a specific cart item by its ID"""
cart_id = get_or_create_cart_id(request)
try:
return CartItem.objects.get(id=item_id, session_id=cart_id)
except CartItem.DoesNotExist:
raise Exception("Cart item not found")
def transfer_cart(request, old_session_key):
"""
Transfer cart items from an anonymous session to an authenticated user's session
"""
from django.contrib.sessions.models import Session
from django.contrib.sessions.backends.db import SessionStore
# Get the old session
try:
old_session = SessionStore(session_key=old_session_key)
# Check if there are cart items in the old session
if 'cart_items' in old_session:
# Transfer cart items to the new session
request.session['cart_items'] = old_session['cart_items']
request.session.modified = True
except Session.DoesNotExist:
pass

@ -1,10 +0,0 @@
from django.conf import settings
def stripe_context(request):
"""Add Stripe-related context variables to templates"""
stripe_mode = getattr(settings, 'STRIPE_MODE', 'test')
return {
'STRIPE_PUBLISHABLE_KEY': settings.STRIPE_PUBLISHABLE_KEY,
'STRIPE_MODE': stripe_mode,
'STRIPE_IS_TEST_MODE': stripe_mode == 'test',
}

@ -1,22 +0,0 @@
from django import forms
from .models import Coupon
from .models import ShippingAddress
class GuestCheckoutForm(forms.Form):
email = forms.EmailField(required=True)
phone = forms.CharField(max_length=20, required=True, label="Téléphone portable")
class CouponApplyForm(forms.Form):
code = forms.CharField(max_length=50)
class ShippingAddressForm(forms.ModelForm):
class Meta:
model = ShippingAddress
fields = ['street_address', 'apartment', 'city', 'postal_code', 'country']
widgets = {
'street_address': forms.TextInput(attrs={'placeholder': 'Adresse'}),
'apartment': forms.TextInput(attrs={'placeholder': 'Appartement (optionnel)'}),
'city': forms.TextInput(attrs={'placeholder': 'Ville'}),
'postal_code': forms.TextInput(attrs={'placeholder': 'Code postal'}),
'country': forms.TextInput(attrs={'placeholder': 'Pays'}),
}

@ -1,206 +0,0 @@
from django.core.management.base import BaseCommand
from shop.models import Color, Size, Product
from django.conf import settings
class Command(BaseCommand):
help = 'Creates initial data for the shop'
def handle(self, *args, **kwargs):
# Create colors
self.stdout.write('Creating colors...')
colors = [
{'name': 'Blanc', 'hex': '#FFFFFF', 'secondary_hex': None, 'ordering': 9},
{'name': 'Blanc / Bleu Sport', 'hex': '#FFFFFF', 'secondary_hex': '#112B44', 'ordering': 10},
{'name': 'Blanc / Gris Clair', 'hex': '#FFFFFF', 'secondary_hex': '#D3D3D3', 'ordering': 12},
{'name': 'Bleu Sport', 'hex': '#112B44', 'secondary_hex': None, 'ordering': 20},
{'name': 'Bleu Sport / Blanc', 'hex': '#112B44', 'secondary_hex': '#FFFFFF', 'ordering': 11},
{'name': 'Bleu Sport / Bleu Sport Chiné', 'hex': '#112B44', 'secondary_hex': '#16395A', 'ordering': 22},
{'name': 'Fuchsia', 'hex': '#C1366B', 'secondary_hex': None, 'ordering': 30},
{'name': 'Corail / Noir', 'hex': '#FF7F50', 'secondary_hex': '#000000', 'ordering': 40},
{'name': 'Gris Foncé Chiné / Noir', 'hex': '#4D4D4D', 'secondary_hex': '#000000', 'ordering': 50},
{'name': 'Olive', 'hex': '#635E53', 'secondary_hex': None, 'ordering': 54},
{'name': 'Kaki Foncé', 'hex': '#707163', 'secondary_hex': None, 'ordering': 55},
{'name': 'Noir', 'hex': '#000000', 'secondary_hex': None, 'ordering': 60},
{'name': 'Noir / Corail', 'hex': '#000000', 'secondary_hex': '#FF7F50', 'ordering': 61},
{'name': 'Noir / Gris Foncé Chiné', 'hex': '#000000', 'secondary_hex': '#4D4D4D', 'ordering': 62},
{'name': 'Rose Clair', 'hex': '#E7C8CF', 'secondary_hex': None, 'ordering': 31},
{'name': 'Sand', 'hex': '#B4A885', 'secondary_hex': None, 'ordering': 32},
]
color_objects = {}
for color_data in colors:
color, created = Color.objects.get_or_create(
name=color_data['name'],
defaults={
'colorHex': color_data['hex'],
'secondary_hex_color': color_data['secondary_hex'],
'ordering': color_data['ordering']
}
)
color_objects[color_data['name']] = color
if created:
self.stdout.write(f'Created color: {color_data["name"]}')
else:
color.colorHex = color_data['hex']
color.secondary_hex_color = color_data['secondary_hex']
color.ordering = color_data['ordering']
color.save()
self.stdout.write(f'Updated color: {color_data["name"]}')
# Create sizes
self.stdout.write('Creating sizes...')
sizes = ['Taille Unique', 'XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL']
size_objects = {}
for name in sizes:
size, created = Size.objects.get_or_create(name=name)
size_objects[name] = size
if created:
self.stdout.write(f'Created size: {name}')
else:
self.stdout.write(f'Size already exists: {name}')
# Create products
self.stdout.write('Creating products...')
products = [
{
'sku': 'PC001',
'title': 'Padel Club Cap',
'description': 'Casquette logo centre',
'price': 25.00,
'ordering_value': 1,
'cut': 0, # Unisex
'colors': ['Blanc', 'Bleu Sport', 'Noir'],
'sizes': ['Taille Unique'],
'image_filename': 'hat.jpg'
},
{
'sku': 'PC002',
'title': 'Padel Club Hoodie Femme',
'description': 'Hoodie femme logo cœur et dos',
'price': 50.00,
'ordering_value': 10,
'cut': 1,
'colors': ['Blanc', 'Bleu Sport', 'Kaki Foncé', 'Noir', 'Fuchsia'],
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
'image_filename': 'PS_K473_WHITE.png.avif'
},
{
'sku': 'PC003',
'title': 'Padel Club Hoodie Homme',
'description': 'Hoodie homme logo cœur et dos',
'price': 50.00,
'ordering_value': 11,
'cut': 2,
'colors': ['Blanc', 'Bleu Sport', 'Kaki Foncé', 'Noir', 'Fuchsia'],
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL'],
'image_filename': 'PS_K476_WHITE.png.avif'
},
{
'sku': 'PC004',
'title': 'Débardeur Femme',
'description': 'Débardeur femme avec logo coeur.',
'price': 25.00,
'ordering_value': 20,
'cut': 1, # Women
'colors': ['Blanc / Bleu Sport', 'Noir / Corail', 'Noir / Gris Foncé Chiné'],
'sizes': ['XS', 'S', 'M', 'L', 'XL'],
'image_filename': 'PS_PA4031_WHITE-SPORTYNAVY.png.avif'
},
{
'sku': 'PC005',
'title': 'Jupe bicolore Femme',
'description': 'Avec short intégré logo jambe (sauf corail)',
'price': 30.00,
'ordering_value': 30,
'cut': 1, # Women
'colors': ['Blanc / Bleu Sport', 'Bleu Sport / Blanc', 'Corail / Noir', 'Noir / Gris Foncé Chiné'],
'sizes': ['XS', 'S', 'M', 'L', 'XL'],
'image_filename': 'PS_PA1031_WHITE-SPORTYNAVY.png.avif'
},
{
'sku': 'PC006',
'title': 'T-shirt Bicolore Homme',
'description': 'T-shirt bicolore avec logo coeur.',
'price': 25.00,
'ordering_value': 40,
'cut': 2, # Men
'colors': ['Blanc / Gris Clair', 'Bleu Sport / Blanc', 'Bleu Sport / Bleu Sport Chiné', 'Noir', 'Noir / Gris Foncé Chiné'],
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'],
'image_filename': 'tshirt_h.png'
},
{
'sku': 'PC007',
'title': 'Short Bicolore Homme',
'description': 'Short bicolore avec logo jambe.',
'price': 30.00,
'ordering_value': 50,
'cut': 2, # Men
'colors': ['Blanc / Bleu Sport', 'Blanc / Gris Clair', 'Noir', 'Gris Foncé Chiné / Noir'],
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'],
'image_filename': 'PS_PA1030_WHITE-SPORTYNAVY.png.avif'
},
{
'sku': 'PC008',
'title': 'T-shirt Simple Femme',
'description': 'T-shirt simple avec logo coeur.',
'price': 20.00,
'ordering_value': 60,
'cut': 1, # Women
'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Kaki Foncé', 'Rose Clair'],
'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL'],
'image_filename': 'PS_PA439_WHITE.png.avif'
},
{
'sku': 'PC009',
'title': 'T-shirt Simple Homme',
'description': 'T-shirt simple avec logo coeur.',
'price': 20.00,
'ordering_value': 61,
'cut': 2, # Men
'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Olive', 'Rose Clair'],
'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL', '3XL'],
'image_filename': 'PS_PA438_WHITE.png.avif'
},
]
for product_data in products:
product, created = Product.objects.update_or_create(
sku=product_data['sku'],
defaults={
'title': product_data['title'],
'description': product_data.get('description', ''),
'price': product_data['price'],
'ordering_value': product_data['ordering_value'],
'cut': product_data['cut']
}
)
if created:
self.stdout.write(f'Created product: {product_data["sku"]} - {product_data["title"]}')
else:
self.stdout.write(f'Updated product: {product_data["sku"]} - {product_data["title"]}')
# Handle the image path
if 'image_filename' in product_data and product_data['image_filename']:
image_path = f"{settings.STATIC_URL}shop/images/products/{product_data['image_filename']}"
if product.image != image_path:
product.image = image_path
product.save()
self.stdout.write(f'Updated image path to "{image_path}" for: {product_data["sku"]}')
# Update colors - first clear existing then add new ones
product.colors.clear()
for color_name in product_data['colors']:
if color_name in color_objects:
product.colors.add(color_objects[color_name])
self.stdout.write(f'Updated colors for: {product_data["sku"]}')
# Update sizes - first clear existing then add new ones
product.sizes.clear()
for size_name in product_data['sizes']:
if size_name in size_objects:
product.sizes.add(size_objects[size_name])
self.stdout.write(f'Updated sizes for: {product_data["sku"]}')
self.stdout.write(self.style.SUCCESS('Successfully created/updated shop data'))

@ -1,53 +0,0 @@
# Generated by Django 4.2.11 on 2025-03-17 17:27
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Color',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(choices=[('Red', 'Red'), ('Blue', 'Blue'), ('Green', 'Green'), ('Black', 'Black'), ('White', 'White')], max_length=10, unique=True)),
],
),
migrations.CreateModel(
name='Size',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(choices=[('S', 'Small'), ('M', 'Medium'), ('L', 'Large'), ('XL', 'X-Large')], max_length=5, unique=True)),
],
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('image', models.ImageField(blank=True, null=True, upload_to='products/')),
('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)),
('colors', models.ManyToManyField(blank=True, related_name='products', to='shop.color')),
('sizes', models.ManyToManyField(blank=True, related_name='products', to='shop.size')),
],
),
migrations.CreateModel(
name='CartItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('session_id', models.CharField(blank=True, max_length=255, null=True)),
('date_added', models.DateTimeField(auto_now_add=True)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.product')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

@ -1,39 +0,0 @@
# Generated by Django 4.2.11 on 2025-03-17 17:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0001_initial'),
]
operations = [
migrations.RenameModel(
old_name='Color',
new_name='ProductColor',
),
migrations.RenameModel(
old_name='Size',
new_name='ProductSize',
),
migrations.RemoveField(
model_name='product',
name='colors',
),
migrations.RemoveField(
model_name='product',
name='sizes',
),
migrations.AddField(
model_name='product',
name='product_colors',
field=models.ManyToManyField(blank=True, to='shop.productcolor'),
),
migrations.AddField(
model_name='product',
name='product_sizes',
field=models.ManyToManyField(blank=True, to='shop.productsize'),
),
]

@ -1,39 +0,0 @@
# Generated by Django 4.2.11 on 2025-03-17 17:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0002_rename_color_productcolor_rename_size_productsize_and_more'),
]
operations = [
migrations.RenameModel(
old_name='ProductColor',
new_name='Color',
),
migrations.RenameModel(
old_name='ProductSize',
new_name='Size',
),
migrations.RemoveField(
model_name='product',
name='product_colors',
),
migrations.RemoveField(
model_name='product',
name='product_sizes',
),
migrations.AddField(
model_name='product',
name='colors',
field=models.ManyToManyField(blank=True, related_name='products', to='shop.color'),
),
migrations.AddField(
model_name='product',
name='sizes',
field=models.ManyToManyField(blank=True, related_name='products', to='shop.size'),
),
]

@ -1,24 +0,0 @@
# Generated by Django 4.2.11 on 2025-03-18 08:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('shop', '0003_rename_productcolor_color_rename_productsize_size_and_more'),
]
operations = [
migrations.AddField(
model_name='cartitem',
name='color',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.color'),
),
migrations.AddField(
model_name='cartitem',
name='size',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.size'),
),
]

@ -1,23 +0,0 @@
# Generated by Django 4.2.11 on 2025-03-18 08:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0004_cartitem_color_cartitem_size'),
]
operations = [
migrations.AlterField(
model_name='color',
name='name',
field=models.CharField(choices=[('Red', 'Red'), ('Blue', 'Blue'), ('Green', 'Green'), ('Black', 'Black'), ('White', 'White')], max_length=20, unique=True),
),
migrations.AlterField(
model_name='size',
name='name',
field=models.CharField(choices=[('S', 'Small'), ('M', 'Medium'), ('L', 'Large'), ('XL', 'X-Large'), ('SINGLE', 'Unique')], max_length=20, unique=True),
),
]

@ -1,22 +0,0 @@
# Generated by Django 4.2.11 on 2025-03-18 13:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0005_alter_color_name_alter_size_name'),
]
operations = [
migrations.AlterModelOptions(
name='product',
options={'ordering': ['order']},
),
migrations.AddField(
model_name='product',
name='order',
field=models.IntegerField(default=0),
),
]

@ -1,18 +0,0 @@
# Generated by Django 4.2.11 on 2025-03-18 13:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0006_alter_product_options_product_order'),
]
operations = [
migrations.AddField(
model_name='product',
name='cut',
field=models.IntegerField(choices=[(1, 'Women'), (2, 'Men'), (3, 'Kids')], default=2),
),
]

@ -1,22 +0,0 @@
# Generated by Django 4.2.11 on 2025-03-18 14:24
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('shop', '0007_product_cut'),
]
operations = [
migrations.AlterModelOptions(
name='product',
options={'ordering': ['ordering_value', 'cut']},
),
migrations.RenameField(
model_name='product',
old_name='order',
new_name='ordering_value',
),
]

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save