Compare commits

..

No commits in common. 'main' and 'sync_v2' have entirely different histories.

  1. 1
      .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. 126
      api/serializers.py
  9. 15
      api/urls.py
  10. 1283
      api/utils.py
  11. 436
      api/views.py
  12. 534
      biz/admin.py
  13. 70
      biz/admin_urls.py
  14. 163
      biz/filters.py
  15. 61
      biz/forms.py
  16. 103
      biz/migrations/0001_initial.py
  17. 18
      biz/migrations/0002_alter_prospect_email.py
  18. 18
      biz/migrations/0003_alter_activity_status.py
  19. 18
      biz/migrations/0004_prospect_contact_again.py
  20. 38
      biz/migrations/0005_alter_activity_status_campaign.py
  21. 19
      biz/migrations/0006_alter_campaign_id.py
  22. 37
      biz/migrations/0007_prospectgroup_delete_campaign.py
  23. 23
      biz/migrations/0008_alter_activity_declination_reason_and_more.py
  24. 0
      biz/migrations/__init__.py
  25. 221
      biz/models.py
  26. 448
      biz/templates/admin/biz/dashboard.html
  27. 81
      biz/templates/admin/biz/email_users.html
  28. 11
      biz/templates/admin/biz/prospect/change_list.html
  29. 53
      biz/templates/admin/biz/prospect/import_file.html
  30. 29
      biz/templates/admin/biz/select_email_template.html
  31. 0
      biz/templatetags/__init__.py
  32. 7
      biz/templatetags/crm_tags.py
  33. 284
      biz/views.py
  34. 0
      crm/__init__.py
  35. 1
      crm/_instructions/base.md
  36. 96
      crm/admin.py
  37. 4
      crm/apps.py
  38. 23
      crm/filters.py
  39. 46
      crm/forms.py
  40. 94
      crm/migrations/0001_initial.py
  41. 60
      crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py
  42. 32
      crm/migrations/0003_remove_prospect_region_prospect_address_and_more.py
  43. 32
      crm/migrations/0004_remove_prospect_name_prospect_entity_name_and_more.py
  44. 18
      crm/migrations/0005_prospect_phone.py
  45. 0
      crm/migrations/__init__.py
  46. 4
      crm/mixins.py
  47. 120
      crm/models.py
  48. 0
      crm/services.py
  49. 6
      crm/static/crm/js/prospects.js
  50. 2
      crm/templates/crm/add_prospect.html
  51. 0
      crm/templates/crm/base.html
  52. 2
      crm/templates/crm/csv_import.html
  53. 4
      crm/templates/crm/event_form.html
  54. 4
      crm/templates/crm/event_row.html
  55. 18
      crm/templates/crm/events.html
  56. 2
      crm/templates/crm/prospect_form.html
  57. 14
      crm/templates/crm/prospect_list.html
  58. 4
      crm/templates/crm/send_bulk_email.html
  59. 0
      crm/templatetags/__init__.py
  60. 7
      crm/templatetags/crm_tags.py
  61. 0
      crm/tests.py
  62. 2
      crm/urls.py
  63. 284
      crm/views.py
  64. 21
      padelclub_backend/settings.py
  65. 60
      padelclub_backend/settings_app.py
  66. 1
      padelclub_backend/settings_local.py.dist
  67. 21
      padelclub_backend/urls.py
  68. 2
      requirements.txt
  69. 11
      sample_prospects.csv
  70. 118
      shop/admin.py
  71. 163
      shop/templates/admin/shop/dashboard.html
  72. 5
      shop/templates/admin/shop/order/change_list.html
  73. 4
      shop/templates/shop/partials/navigation_base.html
  74. 2
      shop/templates/shop/product_list.html
  75. 21
      sync/README.md
  76. 15
      sync/admin.py
  77. 18
      sync/migrations/0007_dataaccess_store_id.py
  78. 18
      sync/migrations/0008_alter_dataaccess_store_id.py
  79. 17
      sync/migrations/0009_alter_dataaccess_options.py
  80. 194
      sync/model_manager.py
  81. 105
      sync/models/base.py
  82. 51
      sync/models/data_access.py
  83. 32
      sync/registry.py
  84. 146
      sync/signals.py
  85. 49
      sync/utils.py
  86. 111
      sync/views.py
  87. 14
      sync/ws_sender.py
  88. 375
      tournaments/admin.py
  89. 2529
      tournaments/admin_utils.py
  90. 22
      tournaments/custom_views.py
  91. 83
      tournaments/filters.py
  92. 60
      tournaments/forms.py
  93. 1235
      tournaments/management/commands/analyze_rankings.py
  94. 222
      tournaments/management/commands/test_fft_all_tournaments.py
  95. 103
      tournaments/management/commands/test_fft_scraper.py
  96. 53
      tournaments/middleware.py
  97. 18
      tournaments/migrations/0129_tournament_currency_code.py
  98. 28
      tournaments/migrations/0130_playerregistration_contact_email_and_more.py
  99. 18
      tournaments/migrations/0131_alter_playerregistration_contact_name.py
  100. 20
      tournaments/migrations/0132_alter_purchase_user.py
  101. Some files were not shown because too many files have changed in this diff Show More

1
.gitignore vendored

@ -7,7 +7,6 @@ padelclub_backend/settings_local.py
myenv/
shared/config_local.py
logs/
# Byte-compiled / optimized / DLL files
__pycache__/

@ -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,5 +1,10 @@
from rest_framework import serializers
from tournaments.models.court import Court
from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer, Image
from django.db.utils import IntegrityError
from django.conf import settings
# email
from django.template.loader import render_to_string
from django.utils.http import urlsafe_base64_encode
from django.utils.encoding import force_bytes
@ -7,12 +12,10 @@ 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 shared.cryptography import encryption_util
from tournaments.models.draw_log import DrawLog
from tournaments.models.enums import UserOrigin, RegistrationPaymentMode
from biz.models import Activity, Prospect, Entity
class EncryptedUserField(serializers.Field):
def to_representation(self, value):
@ -49,7 +52,7 @@ class UserSerializer(serializers.ModelSerializer):
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 :)")
raise IntegrityError("Cet identifiant est déjà utilisé. Veuillez en choisir un autre :)")
user = CustomUser.objects.create_user(
username=validated_data['username'],
@ -134,102 +137,6 @@ class TournamentSerializer(serializers.ModelSerializer):
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()
class EventSerializer(serializers.ModelSerializer):
class Meta:
#club_id = serializers.PrimaryKeyRelatedField(queryset=Club.objects.all())
@ -345,20 +252,3 @@ class ImageSerializer(serializers.ModelSerializer):
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__'

@ -8,10 +8,9 @@ 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'user-agents', views.ShortUserViewSet)
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)
@ -30,17 +29,12 @@ 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"),
@ -49,10 +43,6 @@ urlpatterns = [
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'),
# authentication
path("change-password/", ChangePasswordView.as_view(), name="change_password"),
@ -63,6 +53,5 @@ urlpatterns = [
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,24 +1,5 @@
from pandas.core.groupby import base
from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes
from rest_framework import status
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.permissions import IsAuthenticated
from .authentication import HasAPIKey
from django.conf import settings
from django.http import Http404, HttpResponse, JsonResponse
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 .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 .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer, ImageSerializer
from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image
from tournaments.services.email_service import TournamentEmailService
from biz.models import Activity, Prospect, Entity
from rest_framework import viewsets
from rest_framework.response import Response
@ -30,19 +11,25 @@ from django.http import Http404
from django.db.models import Q
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 .utils import check_version_smaller_than_1_1_12
from shared.discord import send_discord_log_message
from tournaments.services.payment_service import PaymentService
from tournaments.utils.extensions import create_random_filename
from rest_framework.decorators import permission_classes
from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404
from tournaments.services.payment_service import PaymentService
from django.conf import settings
import stripe
import json
import pandas as pd
from tournaments.utils.extensions import create_random_filename
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
import os
from django.http import HttpResponse
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
@ -77,32 +64,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 +71,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()
@ -343,13 +298,13 @@ class UnregisteredPlayerViewSet(SoftDeleteViewSet):
return self.queryset.filter(unregistered_team__tournament__event__creator=self.request.user)
return []
class SupervisorViewSet(viewsets.ModelViewSet):
class ShortUserViewSet(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
return self.request.user.agents
class ImageViewSet(viewsets.ModelViewSet):
"""
@ -520,13 +475,8 @@ def create_stripe_account_link(request):
}, 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/"
@ -618,359 +568,3 @@ def validate_stripe_account(request):
'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,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,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,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 = ('entity_name', 'first_name', 'last_name', 'email', 'address', 'zip_code', 'city', 'created_at')
list_filter = ('zip_code', 'created_at')
search_fields = ('entity_name', 'first_name', 'last_name', 'email', 'zip_code', 'city')
filter_horizontal = ('users',)
date_hierarchy = 'created_at'
@admin.register(Status)
class StatusAdmin(admin.ModelAdmin):
list_display = ('name', 'created_at')
search_fields = ('name',)
@admin.register(ProspectStatus)
class ProspectStatusAdmin(admin.ModelAdmin):
list_display = ('prospect', 'status', 'created_at')
list_filter = ('status', 'created_at')
search_fields = ('prospect__name', 'prospect__email')
date_hierarchy = 'created_at'
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ('get_event_display', 'type', 'date', 'status', 'created_at')
list_filter = ('type', 'status', 'date')
search_fields = ('description',)
filter_horizontal = ('prospects',)
date_hierarchy = 'date'
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,23 @@
import django_filters
from django.db.models import Q
from .models import Event, Status, Prospect
class ProspectFilter(django_filters.FilterSet):
zip_code = django_filters.CharFilter(lookup_expr='istartswith', label='Code postal')
events = django_filters.ModelMultipleChoiceFilter(
queryset=Event.objects.all(),
field_name='events',
)
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', 'events', 'zip_code']

@ -0,0 +1,46 @@
from django import forms
from .models import Prospect, Event
import datetime
class SmallTextArea(forms.Textarea):
def __init__(self, *args, **kwargs):
kwargs.setdefault('attrs', {})
kwargs['attrs'].update({
'rows': 2,
'cols': 100,
'style': 'height: 80px; width: 800px;'
})
super().__init__(*args, **kwargs)
class ProspectForm(forms.ModelForm):
class Meta:
model = Prospect
fields = ['entity_name', 'first_name', 'last_name', 'email',
'phone', 'address', 'zip_code', 'city']
class BulkEmailForm(forms.Form):
prospects = forms.ModelMultipleChoiceField(
queryset=Prospect.objects.all(),
widget=forms.CheckboxSelectMultiple
)
subject = forms.CharField(max_length=200)
content = forms.CharField(widget=forms.Textarea)
class EventForm(forms.ModelForm):
prospects = forms.ModelMultipleChoiceField(
queryset=Prospect.objects.all(),
widget=forms.SelectMultiple(attrs={'class': 'select2'}),
required=False
)
description = forms.CharField(widget=SmallTextArea)
attachment_text = forms.CharField(widget=SmallTextArea)
class Meta:
model = Event
fields = ['date', 'type', 'description', 'attachment_text', 'prospects', 'status']
widgets = {
'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
}
class CSVImportForm(forms.Form):
csv_file = forms.FileField()

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

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

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

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

@ -1,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,120 @@
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)
entity_name = models.CharField(max_length=200, null=True, blank=True)
first_name = models.CharField(max_length=200, null=True, blank=True)
last_name = models.CharField(max_length=200, null=True, blank=True)
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)
phone = models.CharField(max_length=25, null=True, blank=True)
users = models.ManyToManyField(get_user_model(), blank=True)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='created_prospects'
)
modified_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='modified_prospects'
)
modified_at = models.DateTimeField(auto_now=True)
class Meta:
permissions = [
("manage_prospects", "Can manage prospects"),
("view_prospects", "Can view prospects"),
]
def full_name(self):
return f'{self.first_name} {self.last_name}'
def __str__(self):
return ' - '.join(filter(None, [self.entity_name, self.first_name, self.last_name, f"({self.email})"]))
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,6 @@
document.getElementById("select-all").addEventListener("change", function () {
const checkboxes = document.getElementsByName("selected_prospects");
for (let checkbox of checkboxes) {
checkbox.checked = this.checked;
}
});

@ -1,4 +1,4 @@
{% extends "biz/base.html" %}
{% extends "crm/base.html" %}
{% block content %}
<div class="container padding-bottom">

@ -1,4 +1,4 @@
{% extends "biz/base.html" %}
{% extends "crm/base.html" %}
{% block content %}
<div class="container mt-4">

@ -1,4 +1,4 @@
{% 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">
@ -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,23 +1,23 @@
{% 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">
<a href="{% url 'crm:prospect-list' %}" class="small-button margin-v20">
Prospects
</a>
<a href="{% url 'biz:add-event' %}" class="small-button margin-v20">
<a href="{% url 'crm: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">
<a href="{% url 'crm:add-prospect' %}" class="small-button margin-v20 left-margin">
Ajouter un prospect
</a>
<a href="{% url 'biz:csv-import' %}" class="small-button margin-v20 left-margin">
<a href="{% url 'crm:csv-import' %}" class="small-button margin-v20 left-margin">
Import
</a>
</div>
@ -31,7 +31,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 %}
@ -47,7 +47,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 %}

@ -1,4 +1,4 @@
{% extends "biz/base.html" %}
{% extends "crm/base.html" %}
{% block head_title %}{{ first_title }}{% endblock %}
{% block first_title %}{{ first_title }}{% endblock %}

@ -1,4 +1,4 @@
{% extends "biz/base.html" %}
{% extends "crm/base.html" %}
{% load static %}
@ -18,15 +18,15 @@
{% 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>
<a href="{% url 'crm: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>
<a href="{% url 'crm:csv-import' %}" class="btn btn-success">Import CSV</a>
<a href="{% url 'crm:send-bulk-email' %}" class="btn btn-primary">Send Email</a>
</div> -->
<span>{{ filter.qs|length }} résultats</span>
@ -60,11 +60,11 @@
{% endfor %}
</td>
<td>
<a href="{% url 'biz:edit-prospect' prospect.id %}">
<a href="{% url 'crm:edit-prospect' prospect.id %}">
<button class="btn btn-sm btn-secondary">Edit</button>
</a>
<a href="{% url 'biz:add-event-for-prospect' prospect.id %}">
<a href="{% url 'crm:add-event-for-prospect' prospect.id %}">
<button class="btn btn-sm btn-secondary">+ Event</button>
</a>
</td>
@ -77,5 +77,5 @@
{% endblock %}
{% block extra_js %}
<script src="{% static 'biz/js/prospects.js' %}"></script>
<script src="{% static 'crm/js/prospects.js' %}"></script>
{% 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,7 +1,7 @@
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'),

@ -0,0 +1,284 @@
# 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, EmailTracker, EmailCampaign, EventType
from .filters import ProspectFilter
from .forms import ProspectForm, 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 prospect_form(request, pk=None):
# Get the prospect instance if pk is provided (edit mode)
prospect = get_object_or_404(Prospect, pk=pk) if pk else None
if request.method == 'POST':
form = ProspectForm(request.POST, instance=prospect)
if form.is_valid():
prospect = form.save(commit=False)
if not pk: # New prospect
prospect.created_by = request.user
prospect.modified_by = request.user
prospect.save()
action = 'updated' if pk else 'added'
messages.success(request,
f'Prospect {prospect.entity_name} has been {action} successfully!')
return redirect('crm:events')
else:
form = ProspectForm(instance=prospect)
context = {
'form': form,
'is_edit': prospect is not None,
'first_title': prospect.entity_name if prospect else 'Add Prospect',
'second_title': prospect.full_name() if prospect else None
}
return render(request, 'crm/prospect_form.html', context)
# @permission_required('crm.view_crm', raise_exception=True)
# def add_prospect(request):
# if request.method == 'POST':
# entity_name = request.POST.get('entity_name')
# first_name = request.POST.get('first_name')
# last_name = request.POST.get('last_name')
# email = request.POST.get('email')
# phone = request.POST.get('phone')
# address = request.POST.get('address')
# zip_code = request.POST.get('zip_code')
# city = request.POST.get('city')
# # region = request.POST.get('region')
# try:
# prospect = Prospect.objects.create(
# entity_name=entity_name,
# first_name=first_name,
# last_name=last_name,
# email=email,
# phone=phone,
# address=address,
# zip_code=zip_code,
# city=city,
# # region=region,
# created_by=request.user,
# modified_by=request.user
# )
# messages.success(request, f'Prospect {name} has been added successfully!')
# return redirect('crm:events') # or wherever you want to redirect after success
# except Exception as e:
# messages.error(request, f'Error adding prospect: {str(e)}')
# return render(request, 'crm/add_prospect.html')
class EventCreateView(CRMAccessMixin, CreateView):
model = Event
form_class = EventForm
template_name = 'crm/event_form.html'
success_url = reverse_lazy('crm:planned_events')
def get_initial(self):
initial = super().get_initial()
prospect_id = self.kwargs.get('prospect_id')
if prospect_id:
initial['prospects'] = [prospect_id]
return initial
def form_valid(self, form):
form.instance.created_by = self.request.user
form.instance.modified_by = self.request.user
return super().form_valid(form)
class EditEventView(CRMAccessMixin, UpdateView):
model = Event
form_class = EventForm
template_name = 'crm/event_form.html'
success_url = reverse_lazy('crm:planned_events')
def form_valid(self, form):
form.instance.modified_by = self.request.user
response = super().form_valid(form)
messages.success(self.request, 'Event updated successfully!')
return response
class StartEventView(CRMAccessMixin, BaseUpdateView):
model = Event
http_method_names = ['post', 'get']
def get(self, request, *args, **kwargs):
return self.post(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
event = get_object_or_404(Event, pk=kwargs['pk'], status='PLANNED')
event.status = 'ACTIVE'
event.save()
if event.type == 'MAIL':
return HttpResponseRedirect(
reverse_lazy('crm:setup_email_campaign', kwargs={'event_id': event.id})
)
elif event.type == 'SMS':
return HttpResponseRedirect(
reverse_lazy('crm:setup_sms_campaign', kwargs={'event_id': event.id})
)
elif event.type == 'PRESS':
return HttpResponseRedirect(
reverse_lazy('crm:setup_press_release', kwargs={'event_id': event.id})
)
messages.success(request, 'Event started successfully!')
return HttpResponseRedirect(reverse_lazy('crm:planned_events'))
class EventListView(CRMAccessMixin, ListView):
model = Event
template_name = 'crm/events.html'
context_object_name = 'events' # We won't use this since we're providing custom context
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['planned_events'] = Event.objects.filter(
status='PLANNED'
).order_by('date')
context['completed_events'] = Event.objects.filter(
status='COMPLETED'
).order_by('-date')
return context
class ProspectListView(CRMAccessMixin, ListView):
model = Prospect
template_name = 'crm/prospect_list.html'
context_object_name = 'prospects'
filterset_class = ProspectFilter
def get_queryset(self):
return super().get_queryset().prefetch_related('prospectstatus_set__status')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['filter'] = self.filterset_class(
self.request.GET,
queryset=self.get_queryset()
)
return context
class CSVImportView(CRMAccessMixin, FormView):
template_name = 'crm/csv_import.html'
form_class = CSVImportForm
success_url = reverse_lazy('prospect-list')
def form_valid(self, form):
csv_file = TextIOWrapper(
form.cleaned_data['csv_file'].file,
encoding='utf-8-sig' # Handle potential BOM in CSV
)
reader = csv.reader(csv_file, delimiter=';') # Using semicolon delimiter
# Skip header if exists
next(reader, None)
created_count = 0
updated_count = 0
error_count = 0
for row in reader:
try:
if len(row) < 10: # Ensure we have enough columns
continue
# Extract data from correct columns
entity_name = row[0].strip()
last_name = row[1].strip()
first_name = row[2].strip()
email = row[3].strip()
phone = row[4].strip()
zip_code = row[8].strip()
city = row[9].strip()
# Try to update existing prospect or create new one
prospect, created = Prospect.objects.update_or_create(
email=email, # Use email as unique identifier
defaults={
'entity_name': entity_name,
'first_name': first_name,
'last_name': last_name,
'phone': phone,
'zip_code': zip_code,
'city': city,
'modified_by': self.request.user,
}
)
if created:
prospect.created_by = self.request.user
prospect.save()
created_count += 1
else:
updated_count += 1
except Exception as e:
error_count += 1
messages.error(
self.request,
f"Error processing row with email {email}: {str(e)}"
)
# Add success message
messages.success(
self.request,
f"Import completed: {created_count} created, {updated_count} updated, {error_count} errors"
)
return super().form_valid(form)
class SendBulkEmailView(CRMAccessMixin, FormView):
template_name = 'crm/send_bulk_email.html'
form_class = BulkEmailForm
success_url = reverse_lazy('crm:prospect-list')
def form_valid(self, form):
prospects = form.cleaned_data['prospects']
subject = form.cleaned_data['subject']
content = form.cleaned_data['content']
# Create Event for this email campaign
event = Event.objects.create(
date=datetime.now(),
type=EventType.MAILING,
description=f"Bulk email: {subject}",
status='COMPLETED',
created_by=self.request.user,
modified_by=self.request.user
)
event.prospects.set(prospects)
# Send emails
success_count, error_count = send_bulk_email(
subject=subject,
content=content,
prospects=prospects
)
# Show result message
messages.success(
self.request,
f"Sent {success_count} emails successfully. {error_count} failed."
)
return super().form_valid(form)

@ -36,8 +36,7 @@ INSTALLED_APPS = [
'sync',
'tournaments',
'shop',
'biz',
'api',
# 'crm',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@ -51,7 +50,6 @@ INSTALLED_APPS = [
'channels_redis',
'django_filters',
'background_task',
'rest_framework_api_key',
]
@ -65,6 +63,9 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'tournaments.middleware.ReferrerMiddleware', # Add this line
'tournaments.middleware.RegistrationCartCleanupMiddleware',
]
ROOT_URLCONF = 'padelclub_backend.urls'
@ -167,9 +168,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,
@ -198,22 +196,13 @@ LOGGING = {
'rotating_file': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOGS_DIR, 'django.log'),
'filename': os.path.join(BASE_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',

@ -1,54 +1,56 @@
# Rest Framework configuration
REST_FRAMEWORK = {
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S.%f%z",
'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_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_PORT = 587
EMAIL_USE_TLS = True
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
},
"qr-code": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "qr-code-cache",
"TIMEOUT": 3600,
'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"
QR_CODE_CACHE_ALIAS = 'qr-code'
SYNC_APPS = {
"sync": {},
"tournaments": {"exclude": ["Log", "FailedApiCall", "DeviceToken", "Image"]},
# 'biz': {},
'sync': {},
'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken', 'Image'] }
}
SYNC_MODEL_CHILDREN_SHARING = {
"Match": ["team_scores", "team_registration", "player_registrations"]
'Match': ['team_scores', 'team_registration', 'player_registrations']
}
STRIPE_CURRENCY = "eur"
STRIPE_CURRENCY = 'eur'
# Add managers who should receive internal emails
SHOP_MANAGERS = [
("Shop Admin", "shop-admin@padelclub.app"),
('Shop Admin', 'shop-admin@padelclub.app'),
# ('Laurent Morvillier', 'laurent@padelclub.app'),
# ('Xavier Rousset', 'xavier@padelclub.app'),
]
SHOP_SITE_ROOT_URL = "https://padelclub.app"
SHOP_SUPPORT_EMAIL = "shop@padelclub.app"
SHOP_SITE_ROOT_URL = 'https://padelclub.app'
SHOP_SUPPORT_EMAIL = 'shop@padelclub.app'

@ -42,7 +42,6 @@ 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': {

@ -18,39 +18,18 @@ 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("", 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',
})
# Serve media files in development
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

@ -18,5 +18,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

@ -4,72 +4,12 @@ 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")
@ -191,57 +131,6 @@ class OrderAdmin(admin.ModelAdmin):
}),
)
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:
@ -305,10 +194,9 @@ class OrderAdmin(admin.ModelAdmin):
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'),
path('prepare-all-orders/', self.admin_site.admin_view(self.prepare_all_orders), name='prepare_all_orders'),
path('prepare-order/<int:order_id>/', self.admin_site.admin_view(self.prepare_order), name='prepare_order'),
path('cancel-and-refund-order/<int:order_id>/', self.admin_site.admin_view(self.cancel_and_refund_order), name='cancel_and_refund_order'),
]
return custom_urls + urls

@ -1,163 +0,0 @@
{% extends "admin/base_site.html" %}
{% load admin_urls %}
{% block title %}Shop Dashboard{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Home</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label='shop' %}">Shop</a>
&rsaquo; Dashboard
</div>
{% endblock %}
{% block content %}
<div class="dashboard">
<div class="dashboard-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin: 20px 0;">
<!-- Order Status Cards -->
<div class="stat-card" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px;">
<h3 style="margin: 0 0 15px 0; color: #495057;">Orders by Status</h3>
<div class="status-list">
{% for status_data in order_status_data %}
<div class="status-item" style="display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #e9ecef;">
<span style="font-weight: 500; color: #495057;">{{ status_data.label }}</span>
<div style="display: flex; align-items: center; gap: 10px;">
<span class="count" style="background: #007bff; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: bold;">
{{ status_data.count }}
</span>
<span class="total" style="color: #28a745; font-weight: 500;">
€{{ status_data.total_amount|floatformat:2 }}
</span>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Total Summary Card -->
<div class="stat-card" style="background: linear-gradient(135deg, #007bff, #0056b3); color: white; border-radius: 8px; padding: 20px;">
<h3 style="margin: 0 0 15px 0;">Total Summary</h3>
<div class="summary-stats">
<div style="margin-bottom: 10px;">
<div style="font-size: 24px; font-weight: bold;">{{ total_orders }}</div>
<div style="opacity: 0.9;">Total Orders</div>
</div>
<div>
<div style="font-size: 24px; font-weight: bold;">€{{ total_revenue|floatformat:2 }}</div>
<div style="opacity: 0.9;">Total Revenue</div>
</div>
</div>
</div>
<!-- Recent Activity Card -->
<div class="stat-card" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px;">
<h3 style="margin: 0 0 15px 0; color: #495057;">Recent Activity</h3>
<div class="recent-stats">
<div style="margin-bottom: 10px;">
<div style="font-size: 18px; font-weight: bold; color: #28a745;">{{ orders_today }}</div>
<div style="color: #6c757d; font-size: 14px;">Orders Today</div>
</div>
<div style="margin-bottom: 10px;">
<div style="font-size: 18px; font-weight: bold; color: #ffc107;">{{ orders_this_week }}</div>
<div style="color: #6c757d; font-size: 14px;">Orders This Week</div>
</div>
<div>
<div style="font-size: 18px; font-weight: bold; color: #17a2b8;">{{ orders_this_month }}</div>
<div style="color: #6c757d; font-size: 14px;">Orders This Month</div>
</div>
</div>
</div>
<!-- Quick Actions Card -->
<div class="stat-card" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px;">
<h3 style="margin: 0 0 15px 0; color: #495057;">Quick Actions</h3>
<div class="quick-actions">
<a href="{% url 'admin:shop_order_changelist' %}"
style="display: block; padding: 8px 12px; margin-bottom: 8px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; text-align: center;">
View All Orders
</a>
<a href="{% url 'admin:shop_order_changelist' %}?show_preparation=1"
style="display: block; padding: 8px 12px; margin-bottom: 8px; background: #28a745; color: white; text-decoration: none; border-radius: 4px; text-align: center;">
Orders to Prepare ({{ orders_to_prepare }})
</a>
<a href="{% url 'admin:shop_product_changelist' %}"
style="display: block; padding: 8px 12px; margin-bottom: 8px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; text-align: center;">
Manage Products
</a>
<a href="{% url 'admin:shop_coupon_changelist' %}"
style="display: block; padding: 8px 12px; background: #ffc107; color: #212529; text-decoration: none; border-radius: 4px; text-align: center;">
Manage Coupons
</a>
</div>
</div>
</div>
<!-- Detailed Status Breakdown -->
<div class="detailed-breakdown" style="background: white; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; margin-top: 20px;">
<h3 style="margin: 0 0 20px 0; color: #495057;">Status Breakdown</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa;">
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 12px; text-align: center; border-bottom: 2px solid #dee2e6;">Count</th>
<th style="padding: 12px; text-align: center; border-bottom: 2px solid #dee2e6;">Percentage</th>
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #dee2e6;">Total Value</th>
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #dee2e6;">Avg Order Value</th>
</tr>
</thead>
<tbody>
{% for status_data in order_status_data %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px; font-weight: 500;">{{ status_data.label }}</td>
<td style="padding: 12px; text-align: center;">
<span style="background: #007bff; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px;">
{{ status_data.count }}
</span>
</td>
<td style="padding: 12px; text-align: center;">{{ status_data.percentage|floatformat:1 }}%</td>
<td style="padding: 12px; text-align: right; color: #28a745; font-weight: 500;">
€{{ status_data.total_amount|floatformat:2 }}
</td>
<td style="padding: 12px; text-align: right;">
{% if status_data.count > 0 %}
€{{ status_data.avg_order_value|floatformat:2 }}
{% else %}
€0.00
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<style>
.dashboard {
max-width: 1200px;
margin: 0 auto;
}
.stat-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.quick-actions a:hover {
opacity: 0.9;
}
@media (max-width: 768px) {
.dashboard-stats {
grid-template-columns: 1fr !important;
}
}
</style>
{% endblock %}

@ -2,11 +2,6 @@
{% block object-tools %}
<ul class="object-tools">
<li>
<a href="dashboard/" class="viewlink" style="background: #28a745; color: white;">
📊 Dashboard
</a>
</li>
<li>
<a href="?show_preparation=1" class="viewlink">
Orders to Prepare ({{ paid_orders_count }})

@ -9,8 +9,4 @@
<a href="{% url 'custom-login' %}">Se connecter</a>
{% endif %}
<a href="{% url 'shop:product_list' %}">La boutique</a>
{% if user.is_authenticated and user.is_staff %}
<a href="{% url 'admin:shop_order_dashboard' %}" class="download-button">Tableau de bord boutique</a>
<a href="{% url 'admin:prepare_all_orders' %}" class="download-button">Préparer commandes</a>
{% endif %}
</nav>

@ -9,7 +9,7 @@
{% include 'shop/partials/navigation_base.html' %}
<div class="info-box" style="border-left: 4px solid #f39200; padding: 12px; margin: 20px 12px;">
<h3 style="color: #505050; margin-top: 0;">Bienvenue sur la boutique Padel Club !</h3>
<h3 style="color: #505050; margin-top: 0;">Bienvenue sur la boutique Padel Club des copains !</h3>
<p style="margin-top: 10px; margin-bottom: 0;"><strong>Photos :</strong> Les photos des vêtements n'ont pas encore tous le logo, la description indique où il se situera.
<p style="margin-top: 10px; margin-bottom: 0;"><strong>Livraison :</strong> Commandez en ligne et récupérez votre commande en main propre lors de votre prochaine session de padel au club. La livraison peut être possible !</p>
</div>

@ -1,21 +0,0 @@
### Synchronization quick ReadMe
- Data class must extend BaseModel
- Admin classes must extend SyncedObjectAdmin to have updates saved in the BaseModel properties
- The SynchronizationApi defines a get and a post service to POST new data, and GET updates. When performing an operation on a data, a ModelLog instance is created with the related information. When performing a GET, we retrieve the list of ModelLogs to sent the new data to the user.
- routing.py defines the URL of the websocket where messages are sent when updates are made. URL is by user.
### Sharing
- Data can be shared between users. To do that, a new DataAccess object can be created to define the owner, the authorized user, and the object id.
- By default, the whole hierarchy of objects are shared, from the data parents to all its children.
- Special data path can be specified for a class by defining a setting
example:
SYNC_MODEL_CHILDREN_SHARING = {
'Match': ['team_scores', 'team_registration', 'player_registrations']
}
Here when sharing a Match, we also share objects accessed through the names of the properties to get TeamScore, TeamRegistration and PlayerRegistration.
- It's also possible to exclude a class from being sharable by setting sharable = False in its definition. In PadelClub, Club is the top entity that links all data together, so we don't want the automatic data scanning to share clubs.

@ -4,14 +4,12 @@ from django.utils import timezone
from .models import BaseModel, ModelLog, DataAccess
class SyncedObjectAdmin(admin.ModelAdmin):
exclude = ('data_access_ids',)
raw_id_fields = ['related_user', 'last_updated_by']
def save_model(self, request, obj, form, change):
if isinstance(obj, BaseModel):
obj.last_updated_by = request.user
# obj.last_update = timezone.now()
obj.last_update = timezone.now()
if obj.related_user is None:
obj.related_user = request.user
super().save_model(request, obj, form, change)
def delete_model(self, request, obj):
@ -24,14 +22,13 @@ class SyncedObjectAdmin(admin.ModelAdmin):
queryset.delete()
class ModelLogAdmin(admin.ModelAdmin):
list_display = ['user', 'formatted_time', 'model_name', 'operation', 'model_id', 'device_id']
list_filter = ['operation', 'model_name', 'user']
list_display = ['user', 'formatted_time', 'operation', 'model_id', 'model_name', 'count']
list_filter = ['user', 'operation', 'model_name']
ordering = ['-date']
search_fields = ['model_id']
readonly_fields = ['date']
class DataAccessAdmin(SyncedObjectAdmin):
list_display = ['id', 'related_user', 'get_shared_users', 'model_name', 'model_id']
list_display = ['related_user', 'get_shared_users', 'model_name', 'model_id']
list_filter = ['related_user', 'shared_with']
ordering = ['-granted_at']

@ -1,18 +0,0 @@
# Generated by Django 5.1 on 2025-06-12 13:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0006_alter_modellog_operation'),
]
operations = [
migrations.AddField(
model_name='dataaccess',
name='store_id',
field=models.CharField(default='', max_length=100),
),
]

@ -1,18 +0,0 @@
# Generated by Django 5.1 on 2025-06-26 12:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0007_dataaccess_store_id'),
]
operations = [
migrations.AlterField(
model_name='dataaccess',
name='store_id',
field=models.CharField(blank=True, default='', max_length=100, null=True),
),
]

@ -1,17 +0,0 @@
# Generated by Django 5.1 on 2025-08-07 16:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('sync', '0008_alter_dataaccess_store_id'),
]
operations = [
migrations.AlterModelOptions(
name='dataaccess',
options={'verbose_name_plural': 'Data Access'},
),
]

@ -1,194 +0,0 @@
from django.conf import settings
from typing import List, Dict
from .registry import model_registry
import logging
logger = logging.getLogger(__name__)
class SyncModelChildrenManager:
"""
Manager class for handling model children sharing configuration.
Reads the SYNC_MODEL_CHILDREN_SHARING setting once and builds a bidirectional
relationship graph for efficient lookup.
"""
def __init__(self):
"""Initialize the manager by reading the Django setting and building the relationship graph."""
self._model_relationships = getattr(
settings,
'SYNC_MODEL_CHILDREN_SHARING',
{}
)
self._relationship_graph = {}
def _build_relationship_graph(self) -> Dict[str, List[List[str]]]:
"""
Build a bidirectional relationship graph.
Returns:
Dict[str, List[List[str]]]: Dictionary where keys are model names and values
are lists of relationship paths (arrays of relationship names).
"""
graph = {}
# Add direct relationships (original models to their children)
for model_name, relationships in self._model_relationships.items():
if model_name not in graph:
graph[model_name] = []
graph[model_name].append(relationships)
# Build reverse relationships (children back to original models)
for original_model_name, relationships in self._model_relationships.items():
try:
current_model = model_registry.get_model(original_model_name)
if current_model is None:
print(f'missing {original_model_name}')
continue
current_reverse_path = []
for relationship_name in relationships:
# Get the related model through _meta
try:
field = None
# Try to find the field in the model's _meta
for f in current_model._meta.get_fields():
if hasattr(f, 'related_name') and f.related_name == relationship_name:
field = f
break
elif hasattr(f, 'name') and f.name == relationship_name:
field = f
break
if field is None:
continue
# Get the related model
if hasattr(field, 'related_model'):
related_model = field.related_model
elif hasattr(field, 'model'):
related_model = field.model
else:
continue
related_model_name = related_model.__name__
# Find the reverse relationship name
reverse_relationship_name = self._find_reverse_relationship(
relationship_name, current_model, related_model
)
if reverse_relationship_name:
current_reverse_path.append(reverse_relationship_name)
# Add the reverse path
if related_model_name not in graph:
graph[related_model_name] = []
# The path back is just the reverse relationship name
graph[related_model_name].append(current_reverse_path[::-1]) # make a reverse copy
current_model = related_model
except Exception as e:
logger.info(f'error 2 > {e}')
# Skip problematic relationships
continue
except Exception as e:
logger.info(f'error > {e}')
continue
return graph
def _find_reverse_relationship(self, original_relationship_name, in_model, for_model):
"""
Find the reverse relationship name from from_model to to_model.
Args:
from_model: The model to search relationships from
to_model: The target model to find relationship to
original_relationship_name: The original relationship name for context
Returns:
str or None: The reverse relationship name if found
"""
# print(f'reverse of {original_relationship_name} in = {in_model} for {for_model} ')
try:
for field in for_model._meta.get_fields():
# Check ForeignKey, OneToOneField fields
# print(f'{for_model} : field name = {field.name} / field.related_model = {field.related_model == in_model}')
if hasattr(field, 'related_model') and field.related_model == in_model:
return field.name
### possible improvements to do here if multiple relationships of the same type
# Check if this field has a related_name that matches our original relationship
# if hasattr(field, 'related_name') and field.related_name == original_relationship_name:
# # This is the reverse of our original relationship
# return field.name
# elif not hasattr(field, 'related_name') or field.related_name is None:
# default_name = f"{in_model._meta.related_query_name}"
# print(f'no related name: {default_name} / {original_relationship_name.rstrip('s')} ')
# if default_name == original_relationship_name.rstrip('s'): # Simple heuristic
# return field.name
# Check reverse relationships
if hasattr(field, 'field') and hasattr(field.field, 'model'):
if field.field.model == in_model:
if field.get_accessor_name() == original_relationship_name:
return field.field.name
except Exception as e:
print(f'!! ERROR = {e}')
pass
return None
def get_relationships(self, model_name: str) -> List[str]:
"""
Get the list of direct relationships for a given model name.
Args:
model_name (str): The name of the model to look up
Returns:
List[str]: List of relationship names for the model.
Returns empty list if model is not found.
"""
return self._model_relationships.get(model_name, [])
def get_relationship_paths(self, model_name: str) -> List[List[str]]:
"""
Get all relationship paths for a given model name.
This includes both direct relationships and reverse paths.
Args:
model_name (str): The name of the model to look up
Returns:
List[List[str]]: List of relationship paths (each path is a list of relationship names).
Returns empty list if model is not found.
"""
if not self._relationship_graph:
self._relationship_graph = self._build_relationship_graph()
# logger.info(f'self._relationship_graph = {self._relationship_graph}')
return self._relationship_graph.get(model_name, [])
def get_relationship_graph(self) -> Dict[str, List[List[str]]]:
"""
Get the complete relationship graph.
Returns:
Dict[str, List[List[str]]]: The complete relationship graph
"""
return self._relationship_graph.copy()
# Create a singleton instance to use throughout the application
sync_model_manager = SyncModelChildrenManager()

@ -1,13 +1,9 @@
from django.db import models
from django.utils.timezone import now
from django.conf import settings
from typing import List, Set
from ..model_manager import sync_model_manager
import logging
logger = logging.getLogger(__name__)
from django.apps import apps
class BaseModel(models.Model):
creation_date = models.DateTimeField(default=now, editable=False)
@ -16,13 +12,10 @@ class BaseModel(models.Model):
last_updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL, related_name='+')
data_access_ids = models.JSONField(default=list)
sharable = True
class Meta:
abstract = True
def save(self, *args, **kwargs):
self.last_update = now()
if self.related_user is None:
self.related_user = self.find_related_user()
if self._state.adding:
@ -35,34 +28,29 @@ class BaseModel(models.Model):
else:
return None
def data_identifier_dict(self):
return {
'model_id': self.id,
'store_id': None
}
def update_data_access_list(self):
if self.sharable == False:
return
related_instances = self.sharing_related_instances()
data_access_ids = set()
for instance in related_instances:
if isinstance(instance, BaseModel) and instance.data_access_ids:
data_access_ids.update(instance.data_access_ids)
# print(f'related_instances = {related_instances}')
self.data_access_ids = list(data_access_ids)
related_instances = self.related_instances()
related_ids = [ri.id for ri in related_instances]
related_ids.append(self.id)
DataAccess = apps.get_model('sync', 'DataAccess')
data_accesses = DataAccess.objects.filter(model_id__in=related_ids)
for data_access in data_accesses:
self.add_data_access_relation(data_access)
# add data_access to children who might not had the relationship
# if data_accesses:
# for child in self.get_children_by_model():
# if len(child.data_access_ids) == 0:
# for data_access in data_accesses:
# self.add_data_access_relation(data_access)
def add_data_access_relation(self, data_access):
if self.sharable == False:
return
str_id = str(data_access.id)
if str_id not in self.data_access_ids:
self.data_access_ids.append(str_id)
def remove_data_access_relation(self, data_access):
if self.sharable == False:
return
try:
self.data_access_ids.remove(str(data_access.id))
except ValueError:
@ -133,8 +121,9 @@ class BaseModel(models.Model):
children_by_model = self.get_children_by_model()
for queryset in children_by_model.values():
for child in queryset:
if isinstance(child, BaseModel):
children.append(child)
# Recursively get children of children
if isinstance(child, BaseModel):
children.extend(child.get_recursive_children(processed_objects))
return children
@ -191,71 +180,15 @@ class BaseModel(models.Model):
for parent in parents_by_model.values():
if isinstance(parent, BaseModel):
if parent.related_user:
print(f'*** related_user found in {parent}')
print(f'related_user found in {parent}')
return parent.related_user
else:
return parent.find_related_user(processed_objects)
return None
def sharing_related_instances(self):
"""
Get all related instances (both children and parents) recursively
"""
instances = []
processed_objects = set()
instances.extend(self.get_shared_children(processed_objects))
processed_objects = set()
instances.extend(self.get_recursive_parents(processed_objects))
return instances
def get_shared_children(self, processed_objects):
relationships_arrays = sync_model_manager.get_relationship_paths(self.__class__.__name__)
instances = []
if relationships_arrays:
for relationships in relationships_arrays:
children = self.get_shared_children_from_relationships(relationships, processed_objects)
instances.extend(children)
else:
children = self.get_recursive_children(processed_objects)
instances.extend(children)
return instances
def get_shared_children_from_relationships(self, relationships, processed_objects):
# print(f'>>> {self.__class__.__name__} : relationships = {relationships}')
current = [self]
for relationship in relationships:
# print(f'> relationship = {relationship}')
values = []
for item in current:
value = getattr(item, relationship)
if hasattr(value, 'all') and callable(value.all):
# This is a queryset from a reverse relationship
for related_obj in value.all():
processed_objects.add(related_obj)
values.extend(value.all())
else:
processed_objects.add(value)
values.append(value)
current = values
# logger.info(f'+++ shared children = {processed_objects}')
return processed_objects
class SideStoreModel(BaseModel):
store_id = models.CharField(max_length=100, default="") # a value matching LeStorage directory sub-stores. Matches the name of the directory.
class Meta:
abstract = True
def data_identifier_dict(self):
return {
'model_id': self.id,
'store_id': self.store_id
}

@ -1,28 +1,22 @@
from django.db import models
from django.utils import timezone
# from django.apps import apps
from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings
from django.db import transaction
from ..registry import model_registry
import uuid
from . import ModelLog, BaseModel
import logging
from . import ModelLog, SideStoreModel, BaseModel
logger = logging.getLogger(__name__)
class DataAccess(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
shared_with = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='shared_data')
model_name = models.CharField(max_length=50)
model_id = models.UUIDField()
store_id = models.CharField(max_length=100, default="", blank=True, null=True) # a value matching LeStorage directory sub-stores. Matches the name of the directory.
granted_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name_plural = "Data Access"
def delete_dependencies(self):
pass
@ -39,45 +33,40 @@ class DataAccess(BaseModel):
"""Create an access log for a list of users """
model_class = model_registry.get_model(self.model_name)
if model_class:
try:
obj = model_class.objects.get(id=self.model_id)
store_id = None
if isinstance(obj, SideStoreModel):
store_id = obj.store_id
for user in users:
logger.info(f'=== create ModelLog for: {operation} > {users}')
existing_log = ModelLog.objects.filter(user=user, model_id=self.model_id, operation=operation).first()
if existing_log:
existing_log.date = timezone.now()
existing_log.model_operation = operation
existing_log.save()
else:
ModelLog.objects.create(
user=user,
model_id=self.model_id,
model_name=self.model_name,
operation=operation,
date=timezone.now(),
store_id=self.store_id
store_id=store_id
)
# existing_log = ModelLog.objects.filter(user=user, model_id=self.model_id, operation=operation).first()
# if existing_log:
# existing_log.date = timezone.now()
# existing_log.model_operation = operation
# existing_log.save()
# else:
# ModelLog.objects.create(
# user=user,
# model_id=self.model_id,
# model_name=self.model_name,
# operation=operation,
# date=timezone.now(),
# store_id=self.store_id
# )
except ObjectDoesNotExist:
pass
else:
logger.warn(f'!!!model not found: {self.model_name}')
print(f'model not found: {self.model_name}')
def add_references(self):
model_class = model_registry.get_model(self.model_name)
if model_class:
try:
obj = model_class.objects.get(id=self.model_id)
related_instance = obj.sharing_related_instances() # here we want instances granted by the DataAccess, including SYNC_MODEL_CHILDREN_SHARING
related_instance = obj.related_instances()
related_instance.append(obj)
with transaction.atomic():
for instance in related_instance:
# logger.info(f'adds DataAccess to {instance.__class__.__name__}')
if isinstance(instance, BaseModel):
instance.add_data_access_relation(self)
instance.save()
@ -91,8 +80,6 @@ class DataAccess(BaseModel):
obj = model_class.objects.get(id=self.model_id)
related_instance = obj.related_instances()
related_instance.append(obj)
with transaction.atomic():
for instance in related_instance:
if isinstance(instance, BaseModel):
instance.remove_data_access_relation(self)

@ -1,25 +1,22 @@
from django.conf import settings
from django.apps import apps
from .models import BaseModel
from django.contrib.auth import get_user_model
import threading
import logging
import importlib
logger = logging.getLogger(__name__)
User = get_user_model()
class ModelRegistry:
def __init__(self):
self._registry = {}
def load_sync_apps(self):
base_model = get_abstract_model_class('BaseModel')
sync_apps = getattr(settings, 'SYNC_APPS', {})
for app_label, config in sync_apps.items():
app_models = apps.get_app_config(app_label).get_models()
for model in app_models:
if hasattr(model, '_meta') and not model._meta.abstract:
if issubclass(model, base_model) or model == get_user_model():
if issubclass(model, BaseModel) or model == User:
model_name = model.__name__
if self.should_sync_model(model_name, config):
self.register(model)
@ -42,10 +39,6 @@ class ModelRegistry:
# Global instance
model_registry = ModelRegistry()
def get_abstract_model_class(model_name):
module = importlib.import_module('sync.models')
return getattr(module, model_name)
class DeviceRegistry:
"""Thread-safe registry to track device IDs associated with model instances."""
@ -91,35 +84,24 @@ class RelatedUsersRegistry:
def register(self, instance_id, users):
"""Register a device_id for a model instance ID."""
# logger.info(f'USER REGISTRY register {instance_id} : {users}')
with self._lock:
instance_id_str = str(instance_id)
if instance_id_str in self._registry:
existing_users = self._registry[instance_id_str]
self._registry[instance_id_str] = existing_users.union(users)
else:
self._registry[instance_id_str] = set(users) # we convert to set because transmitted query_set are emptying themselves
self._registry[instance_id_str] = users
def get_users(self, instance_id):
"""Get the device_id for a model instance ID."""
with self._lock:
instance_id_str = str(instance_id)
if instance_id_str in self._registry:
# logger.info(f'###### get_users exists ! {instance_id}')
return self._registry[instance_id_str]
else:
# logger.info(f'####### get_users {instance_id} not referenced !')
return {}
# return self._registry.get(instance_id_str)
return self._registry.get(str(instance_id))
def unregister(self, instance_id):
"""Remove an instance from the registry."""
# logger.info(f'USER REGISTRY unregister {instance_id}')
with self._lock:
instance_id_str = str(instance_id)
if instance_id_str in self._registry:
del self._registry[instance_id_str]
if instance_id in self._registry:
del self._registry[instance_id]
# Global instance
related_users_registry = RelatedUsersRegistry()

@ -6,14 +6,10 @@ from .models import DataAccess, ModelLog, ModelOperation, BaseModel, SideStoreMo
from authentication.models import Device
from django.contrib.auth import get_user_model
from django.utils import timezone
from .ws_sender import websocket_sender
from .registry import device_registry, related_users_registry, model_registry
import logging
import traceback
logger = logging.getLogger(__name__)
from .registry import device_registry, related_users_registry
User = get_user_model()
@ -22,7 +18,6 @@ User = get_user_model()
@receiver([pre_save, pre_delete])
def presave_handler(sender, instance, **kwargs):
try:
# some other classes are excluded in settings_app.py: SYNC_APPS
if not isinstance(instance, (BaseModel, User)):
return
@ -33,15 +28,13 @@ def presave_handler(sender, instance, **kwargs):
return
users = related_users(instance)
# print(f'* impacted users = {users}')
related_users_registry.register(instance.id, users)
# user_ids = [user.id for user in users]
if signal == pre_save:
detect_foreign_key_changes_for_shared_instances(sender, instance)
except Exception as e:
logger.info(f'*** presave_handler ERROR: {e}')
raise
@receiver([post_save, post_delete])
def synchronization_notifications(sender, instance, created=False, **kwargs):
"""
@ -54,45 +47,47 @@ def synchronization_notifications(sender, instance, created=False, **kwargs):
if not isinstance(instance, BaseModel) and not isinstance(instance, User):
return
model_name = instance.__class__.__name__
if model_registry.get_model(model_name) is None:
return
try:
process_foreign_key_changes(sender, instance, **kwargs)
signal = kwargs.get('signal')
save_model_log_if_possible(instance, signal, created)
notify_impacted_users(instance)
# print(f'!!!!! related_users_registry.unregister for {instance.__class__.__name__} / {signal}')
related_users_registry.unregister(instance.id)
except Exception as e:
logger.info(f'*** ERROR2: {e}')
logger.info(traceback.format_exc())
raise
def notify_impacted_users(instance):
# print(f'*** notify_impacted_users for instance: {instance}')
# user_ids = set()
# # add impacted users
# if isinstance(instance, User):
# user_ids.add(instance.id)
# elif isinstance(instance, BaseModel):
# owner = instance.last_updated_by
# if owner:
# user_ids.add(owner.id)
# if isinstance(instance, BaseModel):
# if hasattr(instance, '_users_to_notify'):
# user_ids.update(instance._users_to_notify)
# else:
# print('no users to notify')
device_id = device_registry.get_device_id(instance.id)
users = related_users_registry.get_users(instance.id)
logger.info(f'>>> notify_impacted_users: {users} for {instance.id}')
if users:
user_ids = [user.id for user in users]
websocket_sender.send_message(user_ids, device_id)
# print(f'notify device: {device_id}, users = {user_ids}')
# for user_id in user_ids:
# websocket_sender.send_user_message(user_id, device_id)
device_registry.unregister(instance.id)
def save_model_log_if_possible(instance, signal, created):
users = related_users_registry.get_users(instance.id)
# logger.info(f'*** save_model_log_if_possible >>> users from registry = {users}, instance = {instance}')
if not users:
logger.warning(f'!!! Registry returned empty users for instance {instance.id} ({instance.__class__.__name__})')
# Try to recalculate users as fallback
users = related_users(instance)
logger.info(f'!!! Recalculated users for fallback: {users}')
# print(f'*** save_model_log >>> users = {users}, instance = {instance}')
if users:
if signal == post_save or signal == pre_save:
if created:
@ -102,32 +97,30 @@ def save_model_log_if_possible(instance, signal, created):
else:
operation = ModelOperation.DELETE
model_name = instance.__class__.__name__
store_id = None
if isinstance(instance, SideStoreModel):
store_id = instance.store_id
# if operation == ModelOperation.DELETE: # delete now unnecessary logs
# ModelLog.objects.filter(model_id=instance.id).delete()
if operation == ModelOperation.DELETE: # delete now unnecessary logs
ModelLog.objects.filter(model_id=instance.id).delete()
# user_ids = [user.id for user in users]
# # print(f'users to notify: {user_ids}')
# instance._users_to_notify = user_ids # save this for the post_save signal
model_name = instance.__class__.__name__
save_model_log(users, operation, model_name, instance.id, store_id)
else:
logger.info(f'!!! Model Log could not be created because no linked user could be found: {instance.__class__.__name__} {instance}, {signal}')
print(f'>>> Model Log could not be created because no linked user could be found: {instance.__class__.__name__} {instance}, {signal}')
def save_model_log(users, model_operation, model_name, model_id, store_id):
device_id = device_registry.get_device_id(model_id)
# logger.info(f'*** creating ModelLogs for: {model_operation} {model_name} : {users}')
try:
with transaction.atomic():
created_logs = []
for user in users:
# logger.info(f'Creating ModelLog for user {user.id} - user exists: {User.objects.filter(id=user.id).exists()}')
model_log = ModelLog(
# print(f'>> creating Model Log for: {model_operation} {model_name}')
logs_to_create = [
ModelLog(
user=user,
operation=model_operation,
model_name=model_name,
@ -135,21 +128,12 @@ def save_model_log(users, model_operation, model_name, model_id, store_id):
store_id=store_id,
device_id=device_id
)
model_log.save()
# logger.info(f'ModelLog saved with ID: {model_log.id}')
created_logs.append(model_log.id)
# Immediate verification within transaction
# immediate_count = ModelLog.objects.filter(id__in=created_logs).count()
# logger.info(f'*** Within transaction: Created {len(created_logs)}, found {immediate_count}')
for user in users
if user.can_synchronize
]
# Verification after transaction commits
# persisted_count = ModelLog.objects.filter(id__in=created_logs).count()
# logger.info(f'*** After transaction: Created {len(created_logs)}, persisted {persisted_count}')
except Exception as e:
logger.error(f'*** Exception during ModelLog creation: {e}', exc_info=True)
raise
with transaction.atomic():
ModelLog.objects.bulk_create(logs_to_create)
# with transaction.atomic():
# for user in users:
@ -192,6 +176,7 @@ def detect_foreign_key_changes_for_shared_instances(sender, instance):
return
data_access_list = related_data_access(instance)
# print(f'FK change > DA count = {len(data_access_list)}')
if data_access_list:
try:
old_instance = sender.objects.get(pk=instance.pk)
@ -265,8 +250,7 @@ def delete_data_access_if_necessary(sender, instance, **kwargs):
if not isinstance(instance, BaseModel):
return
if hasattr(instance, 'id'):
for data_access in DataAccess.objects.filter(model_id=instance.id):
data_access.create_revoke_access_log()
DataAccess.objects.filter(model_id=instance.id).delete()
@receiver(m2m_changed, sender=DataAccess.shared_with.through)
def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs):
@ -274,16 +258,12 @@ def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs):
# print(f'm2m changed = {pk_set}')
users = User.objects.filter(id__in=pk_set)
save_model_log(users, ModelOperation.PUT, DataAccess.__name__, instance.id, None)
with transaction.atomic():
if action == "post_add":
instance.create_access_log(users, 'SHARED_ACCESS')
elif action == "post_remove":
instance.create_access_log(users, 'REVOKED_ACCESS')
device_id = device_registry.get_device_id(instance.id)
# logger.info(f'*** DataAccess m2m_changed > send message to : {pk_set}')
websocket_sender.send_message(pk_set, device_id)
# for user_id in pk_set:
@ -295,46 +275,26 @@ def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs):
@receiver(post_save, sender=DataAccess)
def data_access_post_save(sender, instance, **kwargs):
try:
instance.add_references() # create DataAccess references on hierarchy
if instance.related_user:
evaluate_if_user_should_sync(instance.related_user)
except Exception as e:
logger.info(f'*** ERROR3: {e}')
logger.info(traceback.format_exc())
raise
@receiver(pre_delete, sender=DataAccess)
def revoke_access_after_delete(sender, instance, **kwargs):
# logger.info(f'.........PRE_DELETE DATAACCESS = {instance.id}..........')
try:
instance.cleanup_references()
instance.create_revoke_access_log()
# logger.info(f'*** users to notify data access delete: {instance.shared_with.all()}')
related_users_registry.register(instance.id, instance.shared_with.all())
instance._user = instance.related_user
except Exception as e:
logger.info(f'*** ERROR4: {e}')
logger.info(traceback.format_exc())
raise
@receiver(post_delete, sender=DataAccess)
def data_access_post_delete(sender, instance, **kwargs):
try:
notify_impacted_users(instance)
if not hasattr(instance, '_user') or not instance._user:
return
evaluate_if_user_should_sync(instance._user)
except Exception as e:
logger.info(f'*** ERROR5: {e}')
logger.info(traceback.format_exc())
raise
# logger.info(f'.........POST_DELETE END DATAACCESS = {instance.id}..........')
def related_users(instance):
users = set()
@ -342,13 +302,25 @@ def related_users(instance):
users.add(instance)
elif isinstance(instance, BaseModel):
users.add(instance.related_user)
# users.add(instance.last_updated_by)
# look in hierarchy
# related_instances = instance.related_instances()
# print(f'related_instances = {related_instances}')
# related_users = [ri.related_user for ri in related_instances if isinstance(ri, BaseModel)]
# users.update(related_users)
data_access_list = DataAccess.objects.filter(id__in=instance.data_access_ids)
# look in related DataAccess
# data_access_list = instances_related_data_access(instance, related_instances)
# print(f'instance = {instance.__class__.__name__}, data access count = {len(data_access_list)}')
for data_access in data_access_list:
users.add(data_access.related_user)
users.update(data_access.shared_with.all())
# print(f'find users for {instance.__class__.__name__}, count = {len(users)}')
if isinstance(instance, DataAccess):
users.update(instance.shared_with.all())
return {user for user in users if user is not None}
@ -377,14 +349,9 @@ def evaluate_if_user_should_sync(user):
@receiver(post_save, sender=Device)
def device_created(sender, instance, **kwargs):
try:
if not instance.user:
return
evaluate_if_user_should_sync(instance.user)
except Exception as e:
logger.info(f'*** ERROR6: {e}')
logger.info(traceback.format_exc())
raise
@receiver(pre_delete, sender=Device)
def device_pre_delete(sender, instance, **kwargs):
@ -392,11 +359,6 @@ def device_pre_delete(sender, instance, **kwargs):
@receiver(post_delete, sender=Device)
def device_post_delete(sender, instance, **kwargs):
try:
if not hasattr(instance, '_user') or not instance._user:
return
evaluate_if_user_should_sync(instance._user)
except Exception as e:
logger.info(f'*** ERROR7: {e}')
logger.info(traceback.format_exc())
raise

@ -1,13 +1,10 @@
import importlib
from collections import defaultdict
from django.apps import apps
from .registry import model_registry
from .models import BaseModel, SideStoreModel
from collections import defaultdict
import random
import string
import logging
logger = logging.getLogger(__name__)
def build_serializer_class(model_name):
@ -42,7 +39,6 @@ def get_data(model_name, model_id):
def get_serialized_data_by_id(model_name, model_id):
# print(f'model_name = {model_name}')
model = model_registry.get_model(model_name)
# logger.info(f'model for {model_name} = {model}')
instance = model.objects.get(id=model_id)
serializer = get_serializer(instance, model_name)
return serializer.data
@ -54,7 +50,6 @@ class HierarchyOrganizer:
def __init__(self):
self.levels = [] # List of dictionaries, each representing a level
self.item_levels = {} # Keep track of items and their levels: (model_name, id) -> level
self.children = set()
def add_item(self, model_name, item_data, level):
"""
@ -95,43 +90,3 @@ class HierarchyOrganizer:
if cleaned_dict:
cleaned_levels.append(cleaned_dict)
return cleaned_levels
def add_relations(self, instance):
self.add_related_parents(instance)
self.add_related_children(instance)
def add_related_children(self, instance):
self.children = instance.get_shared_children(set())
def grouped_children(self):
grouped = defaultdict(list)
for instance in self.children:
class_name = instance.__class__.__name__
grouped[class_name].append(instance.data_identifier_dict())
return dict(grouped)
def add_related_parents(self, instance, current_level=0):
"""
Recursively add all parents of an instance to the hierarchy organizer.
Parents are added at a higher level than their children.
"""
parent_models = instance.get_parents_by_model()
for parent_model_name, parent in parent_models.items():
if isinstance(parent, BaseModel):
store_id = None
if isinstance(parent, SideStoreModel):
store_id = parent.store_id
parent_data = {
'model_id': parent.id,
'store_id': store_id
}
# Add parent at the next level
# print(f'*** add parent: {parent_model_name}: {parent.id}')
self.add_item(parent_model_name, parent_data, current_level + 1)
# Recursively process parent's parents
self.add_related_parents(parent, current_level + 1)

@ -24,38 +24,15 @@ from .models import ModelLog, BaseModel, SideStoreModel, DataAccess
from .registry import model_registry, device_registry
from .ws_sender import websocket_sender
import logging
logger = logging.getLogger(__name__)
# class HierarchyApiView(APIView):
def instances_to_dict(instances, models_dict):
for instance in instances:
child_model_name = instance.__class__.__name__
serializer = get_serializer(instance, child_model_name)
if child_model_name not in models_dict:
models_dict[child_model_name] = {}
models_dict[child_model_name][instance.id] = serializer.data
return models_dict
def instances_to_data_identifier_dict(instances, models_dict):
for instance in instances:
if isinstance(instance, BaseModel):
child_model_name = instance.__class__.__name__
if child_model_name not in models_dict:
models_dict[child_model_name] = {}
models_dict[child_model_name][instance.id] = instance.data_identifier_dict()
return models_dict
def add_children_hierarchy(instance, models_dict):
sync_models = getattr(settings, 'SYNC_MODEL_CHILDREN_SHARING', {})
if instance.__class__.__name__ in sync_models:
relationships = sync_models[instance.__class__.__name__]
# 'Match': {'team_scores', 'team_registration', 'player_registrations'}
# print(f'relationships = {relationships}')
print(f'relationships = {relationships}')
current = [instance]
for relationship in relationships:
print(f'> relationship = {relationship}')
@ -98,30 +75,30 @@ def add_children_recursively(instance, models_dict):
models_dict[child_model_name][child.id] = serializer.data
add_children_recursively(child, models_dict)
# def add_parents_with_hierarchy_organizer(instance, hierarchy_organizer, current_level=0):
# """
# Recursively add all parents of an instance to the hierarchy organizer.
# Parents are added at a higher level than their children.
# """
# parent_models = instance.get_parents_by_model()
def add_parents_with_hierarchy_organizer(instance, hierarchy_organizer, current_level=0):
"""
Recursively add all parents of an instance to the hierarchy organizer.
Parents are added at a higher level than their children.
"""
parent_models = instance.get_parents_by_model()
# for parent_model_name, parent in parent_models.items():
# if isinstance(parent, BaseModel):
# store_id = None
# if isinstance(parent, SideStoreModel):
# store_id = parent.store_id
for parent_model_name, parent in parent_models.items():
if isinstance(parent, BaseModel):
store_id = None
if isinstance(parent, SideStoreModel):
store_id = parent.store_id
# parent_data = {
# 'model_id': parent.id,
# 'store_id': store_id
# }
parent_data = {
'model_id': parent.id,
'store_id': store_id
}
# # Add parent at the next level
# print(f'*** add parent: {parent_model_name}: {parent.id}')
# hierarchy_organizer.add_item(parent_model_name, parent_data, current_level + 1)
# Add parent at the next level
print(f'*** add parent: {parent_model_name}: {parent.id}')
hierarchy_organizer.add_item(parent_model_name, parent_data, current_level + 1)
# # Recursively process parent's parents
# add_parents_with_hierarchy_organizer(parent, hierarchy_organizer, current_level + 1)
# Recursively process parent's parents
add_parents_with_hierarchy_organizer(parent, hierarchy_organizer, current_level + 1)
def add_parents_recursively(instance, dictionary):
"""
@ -251,7 +228,7 @@ class SynchronizationApi(APIView):
return Response({"error": f"Invalid date format for last_update: {decoded_last_update}"},
status=status.HTTP_400_BAD_REQUEST)
print(f'>>> {request.user.username} : GET last modifications since: {last_update_str} / converted = {last_update}')
print(f'>>> GET last modifications since: {last_update_str} / converted = {last_update}')
device_id = request.query_params.get('device_id')
logs = self.query_model_logs(last_update, request.user, device_id)
@ -287,6 +264,7 @@ class LogProcessingResult:
def process_logs(self, logs):
"""Process logs to collect basic operations and handle grant/revoke efficiently."""
for log in logs:
self.last_log_date = log.date
try:
if log.operation in ['POST', 'PUT', 'RELATIONSHIP_SET']:
data = get_serialized_data_by_id(log.model_name, log.model_id)
@ -323,10 +301,7 @@ class LogProcessingResult:
self.shared_relationship_sets[log.model_name][log.model_id] = data
elif log.operation == 'SHARED_RELATIONSHIP_REMOVED':
self.shared_relationship_removals[log.model_name].append(log.data_identifier_dict())
self.last_log_date = log.date # set dates after having retrieved informations
except ObjectDoesNotExist:
logger.warning(f'log processing failed, unable to find {log.model_name} : {log.model_id}')
pass
# Convert updates dict to list for each model
@ -361,7 +336,6 @@ class LogProcessingResult:
shared = defaultdict(dict)
grants = defaultdict(dict)
try:
# Process each grant instance
for model_name, instances in self.shared_instances.items():
for model_id, instance in instances.items():
@ -370,8 +344,6 @@ class LogProcessingResult:
add_children_hierarchy(instance, grants)
add_parents_recursively(instance, grants)
except Exception as e:
print(f'ERR = {e}')
# Convert to lists
for model_name in shared:
@ -384,52 +356,44 @@ class LogProcessingResult:
def process_revocations(self):
"""Process revocations and their hierarchies."""
revocations = defaultdict(list)
revocated_relations_organizer = HierarchyOrganizer()
revocations_parents_organizer = HierarchyOrganizer()
# print(f'*** process_revocations: {len(self.revoke_info)}')
# First, collect all revocations
for model_name, items in self.revoke_info.items():
revocations[model_name].extend(items)
# logger.info(f'$$$ process_revocations for {model_name}, items = {len(items)}')
# print(f'*** process_revocations for {model_name}')
# Process parent hierarchies for each revoked item
model = model_registry.get_model(model_name)
for item in items:
# logger.info(f'$$$ item revoked = {item}')
try:
instance = model.objects.get(id=item['model_id'])
# logger.info(f'$$$ process revoked item parents of {model_name} : {item['model_id']}')
revocated_relations_organizer.add_relations(instance)
# print(f'*** process revoked item parents of {model_name} : {item['model_id']}')
add_parents_with_hierarchy_organizer(instance, revocations_parents_organizer)
except model.DoesNotExist:
pass
children = revocated_relations_organizer.grouped_children()
merged_revocations = merge_dicts(revocations, children)
return merged_revocations, revocated_relations_organizer
return revocations, revocations_parents_organizer
def get_response_data(self):
"""Construct the complete response data structure."""
shared, grants = self.process_shared()
revocations, revocated_relations_organizer = self.process_revocations()
revocations, revocations_parents_organizer = self.process_revocations()
# print(f'self.revocations = {dict(revocations)}')
# print(f'self.revocated_relations_organizer = {revocated_relations_organizer.get_organized_data()}')
# print(f'self.deletions = {dict(self.deletions)}')
# print(f'self.shared_relationship_sets = {self.shared_relationship_sets}')
# print(f'self.shared_relationship_removals = {self.shared_relationship_removals}')
# logger.info('--------------------- SYNC')
return {
"updates": dict(self.updates),
"deletions": dict(self.deletions),
"shared": dict(shared),
"grants": dict(grants),
"revocations": dict(revocations),
"revocated_relations": revocated_relations_organizer.get_organized_data(),
"revocation_parents": revocations_parents_organizer.get_organized_data(),
"shared_relationship_sets": self.shared_relationship_sets,
"shared_relationship_removals": self.shared_relationship_removals,
"date": self.last_log_date
@ -487,12 +451,3 @@ class DataAccessViewSet(viewsets.ModelViewSet):
if self.request.user:
return self.queryset.filter(Q(related_user=self.request.user) | Q(shared_with__in=[self.request.user]))
return []
def merge_dicts(dict1, dict2):
result = defaultdict(list)
for d in [dict1, dict2]:
for key, value in d.items():
result[key].extend(value)
return dict(result)

@ -1,10 +1,6 @@
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from threading import Timer, Lock
import logging
logger = logging.getLogger(__name__)
class WebSocketSender:
"""
@ -59,23 +55,19 @@ class WebSocketSender:
user_ids_key = frozenset(user_ids)
if not user_ids_key: # No users to send to
logger.info(f'WARNING: no user ids : {user_ids}')
return
with self._debounce_lock:
timer_device_id = device_id
if user_ids_key in self._debounce_registry:
old_timer, old_device_id = self._debounce_registry[user_ids_key]
old_timer, _ = self._debounce_registry[user_ids_key]
old_timer.cancel()
if old_device_id != device_id: # we want to notify all devices if there all multiple ones
timer_device_id = None
new_timer = Timer(
self._buffer_timeout,
self._handle_debounced_action,
args=[user_ids_key, timer_device_id]
args=[user_ids_key, device_id]
)
self._debounce_registry[user_ids_key] = (new_timer, device_id)
self._debounce_registry[user_ids_key] = (new_timer, device_id) # Store new timer and latest device_id
new_timer.start()
def _handle_debounced_action(self, user_ids_key, device_id):

@ -1,49 +1,35 @@
from django.contrib import admin, messages
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils import timezone
from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION
from django.utils.html import escape
from django.urls import reverse, path
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.shortcuts import render
from django.db.models import Avg, Count
from datetime import timedelta, datetime
from biz.models import Prospect, ProspectGroup
from .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image
from .forms import CustomUserCreationForm, CustomUserChangeForm
from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter, UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter, TeamScoreRoundIndexFilter
from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter
from sync.admin import SyncedObjectAdmin
import logging
logger = logging.getLogger(__name__)
class CustomUserAdmin(UserAdmin):
form = CustomUserChangeForm
add_form = CustomUserCreationForm
model = CustomUser
search_fields = ['username', 'email', 'phone', 'first_name', 'last_name', 'licence_id']
filter_horizontal = ('clubs',)
actions = ['convert_to_prospect', 'create_group']
list_display = ['email', 'first_name', 'last_name', 'username', 'date_joined', 'latest_event_club_name', 'is_active', 'event_count', 'origin', 'registration_payment_mode', 'licence_id']
list_filter = ['is_active', 'origin', UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter]
list_filter = ['is_active', 'origin']
ordering = ['-date_joined']
autocomplete_fields = ['supervisors', 'organizers']
fieldsets = [
(None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active', 'date_joined']}),
('Permissions', {'fields': ['is_staff', 'is_superuser', 'groups', 'user_permissions']}),
('Personal Info', {'fields': ['registration_payment_mode', 'clubs', 'country', 'phone', 'licence_id', 'umpire_code']}),
('Tournament Settings', {'fields': [
(None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active', 'registration_payment_mode',
'clubs', 'country', 'phone', 'licence_id', 'umpire_code',
'summons_message_body', 'summons_message_signature', 'summons_available_payment_methods',
'summons_display_format', 'summons_display_entry_fee', 'summons_use_full_custom_message',
'match_formats_default_duration', 'bracket_match_format_preference', 'group_stage_match_format_preference',
'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'origin', 'supervisors', 'organizers', 'should_synchronize', 'can_synchronize'
'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'groups', 'origin', 'agents', 'should_synchronize'
]}),
]
add_fieldsets = [
(
None,
@ -58,69 +44,15 @@ class CustomUserAdmin(UserAdmin):
obj.last_update = timezone.now()
super().save_model(request, obj, form, change)
def create_group(self, request, queryset):
prospects = []
source_value = f"auto_created_{datetime.now().strftime('%Y-%m-%d_%H:%M')}"
for user in queryset:
prospect = Prospect.objects.filter(email=user.email).first()
if prospect:
prospects.append(prospect)
else:
prospect = Prospect.objects.create(
first_name=user.first_name,
last_name=user.last_name,
email=user.email,
phone=user.phone,
official_user=user,
source=source_value
)
prospects.append(prospect)
prospect_group = ProspectGroup.objects.create(
name=f"{datetime.now().strftime('%Y-%m-%d_%H:%M')}",
)
prospect_group.prospects.add(*prospects)
messages.success(request, f'Created prospect group {prospect_group.name} with {queryset.count()} prospects')
create_group.short_description = "Create group with selection"
def convert_to_prospect(self, request, queryset):
created_count = 0
skipped_count = 0
source_value = f"user_conversion_{datetime.now().strftime('%Y-%m-%d_%H:%M')}"
for user in queryset:
if user.email and Prospect.objects.filter(email=user.email).exists():
skipped_count += 1
continue
prospect = Prospect.objects.create(
first_name=user.first_name,
last_name=user.last_name,
email=user.email,
phone=user.phone,
official_user=user,
source=source_value
)
created_count += 1
if created_count > 0:
messages.success(request, f'{created_count} prospect(s) successfully created.')
if skipped_count > 0:
messages.warning(request, f'{skipped_count} user(s) skipped (prospect with same email already exists).')
convert_to_prospect.short_description = "Convert selected users to prospects"
class EventAdmin(SyncedObjectAdmin):
list_display = ['creation_date', 'name', 'club', 'creator', 'tenup_id', 'display_images']
list_filter = ['creator', 'club', 'tenup_id']
search_fields = ['name', 'club__name', 'creator__email']
raw_id_fields = ['related_user', 'creator', 'club']
list_display = ['creation_date', 'name', 'club', 'creator', 'creator_full_name', 'tenup_id', 'display_images']
list_filter = ['creator', 'tenup_id']
raw_id_fields = ['creator']
ordering = ['-creation_date']
readonly_fields = ['display_images_preview']
actions = ['set_club_action']
fieldsets = [
(None, {'fields': ['last_update', 'related_user', 'name', 'club', 'creator', 'creation_date', 'tenup_id']}),
(None, {'fields': ['name', 'club', 'creator', 'creation_date', 'tenup_id']}),
('Images', {'fields': ['display_images_preview'], 'classes': ['collapse']}),
]
@ -147,296 +79,22 @@ class EventAdmin(SyncedObjectAdmin):
return mark_safe(html)
display_images_preview.short_description = 'Images Preview'
def set_club_action(self, request, queryset):
"""Action to set club for selected events"""
from django import forms
from django.contrib.admin.widgets import ForeignKeyRawIdWidget
from django.contrib.admin import helpers
from django.core.exceptions import ValidationError
class ClubSelectionForm(forms.Form):
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
action = forms.CharField(widget=forms.HiddenInput)
club_id = forms.CharField(
label='Club',
required=True,
help_text='Enter Club ID or use the search icon to find a club',
widget=ForeignKeyRawIdWidget(
Event._meta.get_field('club').remote_field,
self.admin_site
)
)
def clean_club_id(self):
club_id = self.cleaned_data['club_id']
try:
club = Club.objects.get(pk=club_id)
return club
except Club.DoesNotExist:
raise ValidationError(f'Club with ID {club_id} does not exist.')
except (ValueError, TypeError) as e:
raise ValidationError(f'Invalid Club ID format: {club_id}')
if 'apply' in request.POST:
form = ClubSelectionForm(request.POST)
if form.is_valid():
club = form.cleaned_data['club_id'] # This is now a Club instance
updated_count = queryset.update(club=club)
self.message_user(
request,
f'Successfully updated {updated_count} event(s) with club: {club.name}',
messages.SUCCESS
)
return None
else:
# Show form errors
self.message_user(
request,
f'Form validation failed. Errors: {form.errors}',
messages.ERROR
)
# Initial form display
form = ClubSelectionForm(initial={
'_selected_action': request.POST.getlist(helpers.ACTION_CHECKBOX_NAME),
'action': 'set_club_action',
})
context = {
'form': form,
'events': queryset,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
'action_name': 'set_club_action',
'title': 'Set Club for Events',
'media': form.media,
'has_change_permission': True,
}
return render(request, 'admin/tournaments/set_club_action.html', context)
set_club_action.short_description = "Set club for selected events"
class TournamentAdmin(SyncedObjectAdmin):
list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator', 'is_canceled']
list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator']
ordering = ['-start_date']
search_fields = ['id', 'federal_level_category']
raw_id_fields = ['last_updated_by', 'event']
def dashboard_view(self, request):
"""Tournament dashboard view with comprehensive statistics"""
# Calculate date ranges
now = timezone.now()
today = now.date()
week_start = today - timedelta(days=today.weekday()) # Monday of this week
week_end = week_start + timedelta(days=6) # Sunday of this week
month_start = today.replace(day=1) # First day of current month
# Tournament statistics - tournaments starting TODAY
tournaments_today = Tournament.objects.filter(
start_date__date=today
).exclude(is_deleted=True)
tournaments_today_private = tournaments_today.filter(is_private=True).count()
tournaments_today_public = tournaments_today.filter(is_private=False).count()
tournaments_today_total = tournaments_today.count()
# Tournament statistics - tournaments starting THIS WEEK
tournaments_this_week = Tournament.objects.filter(
start_date__date__gte=week_start,
start_date__date__lte=week_end
).exclude(is_deleted=True)
tournaments_week_private = tournaments_this_week.filter(is_private=True).count()
tournaments_week_public = tournaments_this_week.filter(is_private=False).count()
tournaments_week_total = tournaments_this_week.count()
# Tournament statistics - tournaments starting THIS MONTH
tournaments_this_month = Tournament.objects.filter(
start_date__date__gte=month_start,
start_date__date__lte=today + timedelta(days=31 - today.day) # End of current month
).exclude(is_deleted=True)
tournaments_month_private = tournaments_this_month.filter(is_private=True).count()
tournaments_month_public = tournaments_this_month.filter(is_private=False).count()
tournaments_month_total = tournaments_this_month.count()
# All time tournament statistics
all_tournaments = Tournament.objects.exclude(is_deleted=True)
tournaments_all_private = all_tournaments.filter(is_private=True).count()
tournaments_all_public = all_tournaments.filter(is_private=False).count()
tournaments_all_total = all_tournaments.count()
# Ended tournaments (tournaments that have an end_date in the past)
tournaments_ended_today = Tournament.objects.filter(
end_date__date=today
).exclude(is_deleted=True)
tournaments_ended_week = Tournament.objects.filter(
end_date__date__gte=week_start,
end_date__date__lte=week_end
).exclude(is_deleted=True)
tournaments_ended_month = Tournament.objects.filter(
end_date__date__gte=month_start,
end_date__date__lte=today + timedelta(days=31 - today.day)
).exclude(is_deleted=True)
tournaments_ended_all = Tournament.objects.filter(
end_date__date__lt=today
).exclude(is_deleted=True)
# Team and player statistics
total_teams = TeamRegistration.objects.count()
total_players = PlayerRegistration.objects.count()
# Match statistics
total_matches = Match.objects.count()
matches_played = Match.objects.filter(end_date__isnull=False).count()
matches_pending = Match.objects.filter(end_date__isnull=True).count()
# Additional statistics
tournaments_with_online_reg = Tournament.objects.filter(
enable_online_registration=True
).exclude(is_deleted=True).count()
tournaments_with_payment = Tournament.objects.filter(
enable_online_payment=True
).exclude(is_deleted=True).count()
# Average statistics
avg_teams_per_tournament = TeamRegistration.objects.aggregate(
avg_teams=Avg('tournament__team_count')
)['avg_teams'] or 0
email_count = PlayerRegistration.objects.aggregate(
total=Count('email', distinct=True)
)['total']
avg_entry_fee = Tournament.objects.exclude(is_deleted=True).aggregate(
avg_fee=Avg('entry_fee')
)['avg_fee'] or 0
# User Account Statistics
total_users = CustomUser.objects.count()
users_admin = CustomUser.objects.filter(origin=0).count() # ADMIN
users_site = CustomUser.objects.filter(origin=1).count() # SITE
users_app = CustomUser.objects.filter(origin=2).count() # APP
# Recent User Registrations
recent_app_users = CustomUser.objects.filter(origin=2).order_by('-date_joined')[:10]
# New users by period
users_today = CustomUser.objects.filter(date_joined__date=today).count()
users_this_week = CustomUser.objects.filter(
date_joined__date__gte=week_start,
date_joined__date__lte=week_end
).count()
users_this_month = CustomUser.objects.filter(
date_joined__date__gte=month_start,
date_joined__date__lte=today + timedelta(days=31 - today.day)
).count()
# Purchase Statistics
total_purchases = Purchase.objects.count()
recent_purchases = Purchase.objects.all().order_by('-purchase_date')[:10]
# Purchases by period
purchases_today = Purchase.objects.filter(purchase_date__date=today).count()
purchases_this_week = Purchase.objects.filter(
purchase_date__date__gte=week_start,
purchase_date__date__lte=week_end
).count()
purchases_this_month = Purchase.objects.filter(
purchase_date__date__gte=month_start,
purchase_date__date__lte=today + timedelta(days=31 - today.day)
).count()
context = {
'title': 'Tournament Dashboard',
'app_label': 'tournaments',
'opts': Tournament._meta,
# Today statistics (tournaments STARTING today)
'tournaments_today_total': tournaments_today_total,
'tournaments_today_private': tournaments_today_private,
'tournaments_today_public': tournaments_today_public,
# Week statistics (tournaments STARTING this week)
'tournaments_week_total': tournaments_week_total,
'tournaments_week_private': tournaments_week_private,
'tournaments_week_public': tournaments_week_public,
# Month statistics (tournaments STARTING this month)
'tournaments_month_total': tournaments_month_total,
'tournaments_month_private': tournaments_month_private,
'tournaments_month_public': tournaments_month_public,
# All time statistics
'tournaments_all_total': tournaments_all_total,
'tournaments_all_private': tournaments_all_private,
'tournaments_all_public': tournaments_all_public,
# Ended tournaments (tournaments ENDING in the respective periods)
'tournaments_ended_today': tournaments_ended_today.count(),
'tournaments_ended_week': tournaments_ended_week.count(),
'tournaments_ended_month': tournaments_ended_month.count(),
'tournaments_ended_all': tournaments_ended_all.count(),
# Teams and players
'total_teams': total_teams,
'total_players': total_players,
# Matches
'total_matches': total_matches,
'matches_played': matches_played,
'matches_pending': matches_pending,
# Additional stats
'tournaments_with_online_reg': tournaments_with_online_reg,
'tournaments_with_payment': tournaments_with_payment,
'avg_teams_per_tournament': round(avg_teams_per_tournament, 1),
'avg_entry_fee': round(avg_entry_fee, 2),
'email_count': email_count,
# User statistics
'total_users': total_users,
'users_admin': users_admin,
'users_site': users_site,
'users_app': users_app,
'users_today': users_today,
'users_this_week': users_this_week,
'users_this_month': users_this_month,
'recent_app_users': recent_app_users,
# Purchase statistics
'total_purchases': total_purchases,
'recent_purchases': recent_purchases,
'purchases_today': purchases_today,
'purchases_this_week': purchases_this_week,
'purchases_this_month': purchases_this_month,
}
return render(request, 'admin/tournaments/dashboard.html', context)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('dashboard/', self.admin_site.admin_view(self.dashboard_view), name='tournaments_tournament_dashboard'),
]
return custom_urls + urls
search_fields = ['id']
class TeamRegistrationAdmin(SyncedObjectAdmin):
list_display = ['player_names', 'group_stage', 'name', 'tournament', 'registration_date']
list_filter = [SimpleTournamentListFilter]
search_fields = ['id']
raw_id_fields = ['related_user', 'tournament']
class TeamScoreAdmin(SyncedObjectAdmin):
list_display = ['team_registration', 'score', 'walk_out', 'match']
list_filter = [TeamScoreRoundIndexFilter, TeamScoreTournamentListFilter]
search_fields = ['id', 'team_registration__player_registrations__first_name', 'team_registration__player_registrations__last_name']
raw_id_fields = ['team_registration', 'match']
list_filter = [TeamScoreTournamentListFilter]
search_fields = ['id']
raw_id_fields = ['team_registration', 'match'] # Add this line
list_per_page = 50 # Controls pagination on the list view
def get_queryset(self, request):
@ -458,7 +116,7 @@ class RoundAdmin(SyncedObjectAdmin):
class PlayerRegistrationAdmin(SyncedObjectAdmin):
list_display = ['first_name', 'last_name', 'licence_id', 'rank']
search_fields = ['id', 'first_name', 'last_name', 'licence_id__icontains']
list_filter = ['registered_online', 'payment_id', TeamScoreTournamentListFilter]
list_filter = ['registered_online', TeamScoreTournamentListFilter]
ordering = ['last_name', 'first_name']
raw_id_fields = ['team_registration'] # Add this line
list_per_page = 50 # Controls pagination on the list view
@ -493,7 +151,6 @@ class PurchaseAdmin(SyncedObjectAdmin):
list_display = ['id', 'user', 'product_id', 'quantity', 'purchase_date', 'revocation_date', 'expiration_date']
list_filter = ['user']
ordering = ['-purchase_date']
raw_id_fields = ['user']
class CourtAdmin(SyncedObjectAdmin):
list_display = ['index', 'name', 'club']

File diff suppressed because it is too large Load Diff

@ -31,35 +31,13 @@ class CustomLoginView(auth_views.LoginView):
# Fall back to default
return reverse('index')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Check for inactive user in session
inactive_user_email = self.request.session.get('inactive_user_email')
if inactive_user_email:
context['inactive_user_email'] = inactive_user_email
context['show_resend_activation'] = True
return context
def get(self, request, *args, **kwargs):
# Capture referrer for anonymous users (replaces middleware functionality)
if not request.user.is_authenticated:
referrer = request.META.get('HTTP_REFERER')
# Only store referrer if it exists and is not the login page itself
if referrer and 'login' not in referrer:
request.session['login_referrer'] = referrer
# Clear any potential password reset session data
keys_to_clear = [key for key in request.session.keys()
if 'reset' in key or 'password' in key]
for key in keys_to_clear:
del request.session[key]
# Clear inactive user session data on GET request (fresh login page)
request.session.pop('inactive_user_email', None)
request.session.pop('inactive_user_id', None)
storage = messages.get_messages(request)
for _ in storage:
pass

@ -1,6 +1,6 @@
from django.contrib import admin
from .models import Tournament, Match, Round
from django.db.models import Q, Count
from .models import Tournament, Match
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from datetime import timedelta
@ -135,82 +135,3 @@ class StartDateRangeFilter(admin.SimpleListFilter):
start_date__gte=today - timedelta(days=3),
start_date__lte=today + timedelta(days=3)
)
class UserWithEventsFilter(admin.SimpleListFilter):
title = _('has events')
parameter_name = 'has_events'
def lookups(self, request, model_admin):
return (
('yes', _('Has events')),
('no', _('No events')),
)
def queryset(self, request, queryset):
if self.value() == 'yes':
return queryset.annotate(events_count=Count('events')).filter(events_count__gt=0)
elif self.value() == 'no':
return queryset.annotate(events_count=Count('events')).filter(events_count=0)
return queryset
class UserWithPurchasesFilter(admin.SimpleListFilter):
title = _('has purchases')
parameter_name = 'has_purchases'
def lookups(self, request, model_admin):
return (
('yes', _('Has purchases')),
('no', _('No purchases')),
)
def queryset(self, request, queryset):
if self.value() == 'yes':
return queryset.annotate(purchases_count=Count('purchases')).filter(purchases_count__gt=0)
elif self.value() == 'no':
return queryset.annotate(purchases_count=Count('purchases')).filter(purchases_count=0)
return queryset
class UserWithProspectFilter(admin.SimpleListFilter):
title = _('has prospect')
parameter_name = 'has_prospect'
def lookups(self, request, model_admin):
return (
('yes', _('Has prospect')),
('no', _('No prospect')),
)
def queryset(self, request, queryset):
from biz.models import Prospect
if self.value() == 'yes':
prospect_emails = Prospect.objects.values_list('email', flat=True).filter(email__isnull=False)
return queryset.filter(email__in=prospect_emails)
elif self.value() == 'no':
prospect_emails = Prospect.objects.values_list('email', flat=True).filter(email__isnull=False)
return queryset.exclude(email__in=prospect_emails)
return queryset
class TeamScoreRoundIndexFilter(admin.SimpleListFilter):
title = _("Round Index")
parameter_name = "round_index"
def lookups(self, request, model_admin):
# Get distinct round indexes from matches that have team scores
round_indexes = Round.objects.filter(
matches__team_scores__isnull=False
).values_list('index', flat=True).distinct().order_by('index')
# Create lookup tuples with round names
lookups = []
for index in round_indexes:
round_obj = Round.objects.filter(index=index, parent__isnull=True).first()
if round_obj:
lookups.append((index, round_obj.name()))
else:
lookups.append((index, f"Index {index}"))
return lookups
def queryset(self, request, queryset):
if self.value():
return queryset.filter(match__round__index=self.value())
return queryset

@ -77,10 +77,10 @@ class SimpleCustomUserCreationForm(UserCreationForm):
def clean_phone(self):
phone = self.cleaned_data.get('phone')
if phone:
# Remove all spaces, dots, dashes, and parentheses
phone = re.sub(r'[\s\.\-\(\)]', '', phone)
# Basic regex for phone numbers, allowing 6-15 digits for international numbers
if not re.match(r"^\+?\d{6,15}$", phone):
# Remove all spaces
phone = phone.replace(' ', '')
# Basic regex for phone numbers, matching common formats
if not re.match(r"^\+?\d{10,15}$", phone):
raise forms.ValidationError("Entrer un numéro de téléphone valide.")
return phone
@ -171,7 +171,7 @@ class SimpleForm(forms.Form):
class TournamentRegistrationForm(forms.Form):
#first_name = forms.CharField(label='Prénom', max_length=50)
#last_name = forms.CharField(label='Nom', max_length=50)
email = forms.EmailField(label='E-mail')
email = forms.EmailField(label='E-mail', widget=forms.EmailInput(attrs={'readonly': 'readonly'}))
mobile_number = forms.CharField(
label='Téléphone',
max_length=15,
@ -181,9 +181,10 @@ class TournamentRegistrationForm(forms.Form):
def clean_mobile_number(self):
mobile_number = self.cleaned_data.get('mobile_number')
if mobile_number:
# Remove spaces, dots, dashes, and parentheses from the number first
mobile_number = re.sub(r'[\s\.\-\(\)]', '', mobile_number)
if not re.match(r"^\+?\d{6,15}$", mobile_number):
# Basic regex for mobile numbers, matching common formats
# Remove spaces from the number first
mobile_number = mobile_number.replace(' ', '')
if not re.match(r"^\+?\d{10,15}$", mobile_number):
raise forms.ValidationError("Entrer un numéro de téléphone valide.")
return mobile_number
@ -291,10 +292,10 @@ class ProfileUpdateForm(forms.ModelForm):
def clean_phone(self):
phone = self.cleaned_data.get('phone')
if phone:
# Remove all spaces, dots, dashes, and parentheses
phone = re.sub(r'[\s\.\-\(\)]', '', phone)
# Basic regex for phone numbers, allowing 6-15 digits for international numbers
if not re.match(r"^\+?\d{6,15}$", phone):
# Remove all spaces
phone = phone.replace(' ', '')
# Basic regex for phone numbers, matching common formats
if not re.match(r"^\+?\d{10,15}$", phone):
raise forms.ValidationError("Entrer un numéro de téléphone valide.")
return phone
@ -337,52 +338,29 @@ class EmailOrUsernameAuthenticationForm(AuthenticationForm):
username = self.cleaned_data.get('username')
password = self.cleaned_data.get('password')
print(f"Login attempt with username/email: {username}") # Debug print
logger.info(f"Login attempt with username/email: {username}")
if username and password:
# Check if user exists first (either by username or email)
user_exists = None
try:
# Try to find user by username first
user_exists = CustomUser.objects.get(username__iexact=username)
except CustomUser.DoesNotExist:
# Try to find user by email
try:
user_exists = CustomUser.objects.get(email__iexact=username)
except CustomUser.DoesNotExist:
pass
# If user exists but is inactive, provide specific feedback
if user_exists and not user_exists.is_active:
# Store the inactive user in session for template use
if hasattr(self, 'request') and self.request.session:
self.request.session['inactive_user_email'] = user_exists.email
self.request.session['inactive_user_id'] = str(user_exists.id)
raise forms.ValidationError(
"Votre compte n'est pas encore activé. Veuillez cliquer sur le lien d'activation envoyé à votre adresse e-mail.",
code='inactive_account'
)
# Try regular authentication
self.user_cache = authenticate(
self.request,
username=username,
password=password
)
print(f"Authentication result: {self.user_cache}") # Debug print
logger.info(f"Authentication result: {self.user_cache}")
if self.user_cache is None:
print("Authentication failed") # Debug print
logger.warning("Authentication failed")
raise forms.ValidationError(
"Identifiant/E-mail ou mot de passe incorrect. Les champs sont sensibles à la casse.",
code='invalid_login'
)
else:
print(f"Authentication successful for user: {self.user_cache}") # Debug print
logger.info(f"Authentication successful for user: {self.user_cache}")
# Clear any inactive user session data on successful login
if hasattr(self, 'request') and self.request.session:
self.request.session.pop('inactive_user_email', None)
self.request.session.pop('inactive_user_id', None)
self.confirm_login_allowed(self.user_cache)
return self.cleaned_data

File diff suppressed because it is too large Load Diff

@ -1,222 +0,0 @@
from django.core.management.base import BaseCommand
from datetime import datetime, timedelta
import logging
class Command(BaseCommand):
help = 'Test FFT all tournaments scraping with various filters'
def add_arguments(self, parser):
parser.add_argument(
'--sorting',
type=str,
default='dateDebut+asc',
choices=['dateDebut+asc', 'dateDebut+desc', '_DISTANCE_'],
help='Sorting option (default: dateDebut+asc)'
)
parser.add_argument(
'--page',
type=int,
default=0,
help='Page number to scrape (default: 0)'
)
parser.add_argument(
'--city',
type=str,
default='',
help='City to search around'
)
parser.add_argument(
'--distance',
type=float,
default=15.0,
help='Distance in km (default: 15)'
)
parser.add_argument(
'--categories',
nargs='*',
default=[],
help='Tournament categories to filter by'
)
parser.add_argument(
'--levels',
nargs='*',
default=[],
help='Tournament levels to filter by'
)
parser.add_argument(
'--ages',
nargs='*',
default=[],
help='Age categories to filter by'
)
parser.add_argument(
'--types',
nargs='*',
default=[],
help='Tournament types to filter by'
)
parser.add_argument(
'--national-cup',
action='store_true',
help='Filter for national cup tournaments only'
)
parser.add_argument(
'--lat',
type=float,
help='Latitude for location-based search'
)
parser.add_argument(
'--lng',
type=float,
help='Longitude for location-based search'
)
parser.add_argument(
'--days-ahead',
type=int,
default=90,
help='How many days ahead to search (default: 90)'
)
parser.add_argument(
'--start-date',
type=str,
help='Start date in DD/MM/YY format (overrides --days-ahead)'
)
parser.add_argument(
'--end-date',
type=str,
help='End date in DD/MM/YY format (overrides --days-ahead)'
)
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose logging'
)
def handle(self, *args, **options):
if options['verbose']:
logging.basicConfig(level=logging.INFO)
# Extract options
sorting_option = options['sorting']
page = options['page']
city = options['city']
distance = options['distance']
categories = options['categories']
levels = options['levels']
ages = options['ages']
tournament_types = options['types']
national_cup = options['national_cup']
lat = options['lat']
lng = options['lng']
verbose = options['verbose']
# Calculate date range
if options['start_date'] and options['end_date']:
start_date_str = options['start_date']
end_date_str = options['end_date']
else:
start_date = datetime.now()
end_date = start_date + timedelta(days=options['days_ahead'])
start_date_str = start_date.strftime('%d/%m/%y')
end_date_str = end_date.strftime('%d/%m/%y')
self.stdout.write(self.style.SUCCESS("=== FFT All Tournaments Scraper ==="))
self.stdout.write(f"Sorting: {sorting_option}")
self.stdout.write(f"Page: {page}")
self.stdout.write(f"Date range: {start_date_str} to {end_date_str}")
self.stdout.write(f"City: {city if city else 'Not specified'}")
self.stdout.write(f"Distance: {distance} km")
self.stdout.write(f"Categories: {categories if categories else 'All'}")
self.stdout.write(f"Levels: {levels if levels else 'All'}")
self.stdout.write(f"Ages: {ages if ages else 'All'}")
self.stdout.write(f"Types: {tournament_types if tournament_types else 'All'}")
self.stdout.write(f"National Cup: {'Yes' if national_cup else 'No'}")
if lat and lng:
self.stdout.write(f"Location: {lat}, {lng}")
self.stdout.write(f"Method: Playwright (Chrome-free)")
self.stdout.write("")
try:
from api.utils import scrape_fft_all_tournaments
self.stdout.write("🚀 Testing general tournament scraping...")
result = scrape_fft_all_tournaments(
sorting_option=sorting_option,
page=page,
start_date=start_date_str,
end_date=end_date_str,
city=city,
distance=distance,
categories=categories,
levels=levels,
lat=lat,
lng=lng,
ages=ages,
tournament_types=tournament_types,
national_cup=national_cup
)
# Debug: Show what we got (only in verbose mode)
if verbose:
self.stdout.write(f"🔍 Raw result: {result}")
if result:
tournaments = result.get('tournaments', [])
self.stdout.write(self.style.SUCCESS(f"✅ SUCCESS: {len(tournaments)} tournaments found"))
if tournaments:
self.stdout.write("\n📝 Sample tournaments:")
# Show first 3 tournaments
for i, tournament in enumerate(tournaments[:3]):
self.stdout.write(f"\n Tournament {i+1}:")
self.stdout.write(f" ID: {tournament.get('id')}")
self.stdout.write(f" Name: {tournament.get('libelle')}")
self.stdout.write(f" Date: {tournament.get('dateDebut', {}).get('date', 'N/A')}")
self.stdout.write(f" Club: {tournament.get('nomClub', 'N/A')}")
self.stdout.write(f" City: {tournament.get('villeEngagement', 'N/A')}")
self.stdout.write(f" Category: {tournament.get('categorieTournoi', 'N/A')}")
self.stdout.write(f" Type: {tournament.get('type', 'N/A')}")
if tournament.get('jugeArbitre'):
self.stdout.write(f" Judge: {tournament.get('jugeArbitre', {}).get('nom', 'N/A')}")
self.stdout.write(f"\n📊 Summary:")
self.stdout.write(f" Total tournaments: {len(tournaments)}")
self.stdout.write(f" Current page: {page}")
self.stdout.write(f" Total results available: {result.get('total_results', 'Unknown')}")
# Analysis of results
if tournaments:
cities = set()
clubs = set()
categories = set()
types = set()
for tournament in tournaments:
if tournament.get('villeEngagement'):
cities.add(tournament['villeEngagement'])
if tournament.get('nomClub'):
clubs.add(tournament['nomClub'])
if tournament.get('categorieTournoi'):
categories.add(tournament['categorieTournoi'])
if tournament.get('type'):
types.add(tournament['type'])
self.stdout.write(f"\n🔍 Analysis:")
self.stdout.write(f" Unique cities: {len(cities)}")
self.stdout.write(f" Unique clubs: {len(clubs)}")
self.stdout.write(f" Unique categories: {len(categories)}")
self.stdout.write(f" Unique types: {len(types)}")
if verbose:
self.stdout.write(f"\n Cities: {sorted(list(cities))[:10]}") # Show first 10
self.stdout.write(f" Categories: {sorted(list(categories))}")
self.stdout.write(f" Types: {sorted(list(types))}")
else:
self.stdout.write(self.style.ERROR("❌ FAILED: No tournaments found"))
except Exception as e:
self.stdout.write(self.style.ERROR(f"❌ ERROR: {e}"))
import traceback
if verbose:
self.stdout.write(traceback.format_exc())

@ -1,103 +0,0 @@
from django.core.management.base import BaseCommand
from datetime import datetime, timedelta
import logging
class Command(BaseCommand):
help = 'Test FFT tournament scraping with Playwright'
def add_arguments(self, parser):
parser.add_argument(
'--club-code',
type=str,
default='62130180',
help='Club code for testing (default: 62130180)'
)
parser.add_argument(
'--club-name',
type=str,
default='TENNIS SPORTING CLUB DE CASSIS',
help='Club name for testing'
)
parser.add_argument(
'--all-pages',
action='store_true',
help='Test all pages scraping'
)
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose logging'
)
def handle(self, *args, **options):
if options['verbose']:
logging.basicConfig(level=logging.INFO)
club_code = options['club_code']
club_name = options['club_name']
all_pages = options['all_pages']
verbose = options['verbose']
# Calculate date range
start_date = datetime.now()
end_date = start_date + timedelta(days=90)
start_date_str = start_date.strftime('%d/%m/%y')
end_date_str = end_date.strftime('%d/%m/%y')
self.stdout.write(self.style.SUCCESS("=== FFT Tournament Scraper ==="))
self.stdout.write(f"Club: {club_name} ({club_code})")
self.stdout.write(f"Date range: {start_date_str} to {end_date_str}")
self.stdout.write(f"Method: Playwright (Chrome-free)")
self.stdout.write("")
try:
if all_pages:
from api.utils import scrape_fft_club_tournaments_all_pages
self.stdout.write("🚀 Testing complete tournament scraping...")
result = scrape_fft_club_tournaments_all_pages(
club_code=club_code,
club_name=club_name,
start_date=start_date_str,
end_date=end_date_str
)
else:
from api.utils import scrape_fft_club_tournaments
self.stdout.write("🚀 Testing single page scraping...")
result = scrape_fft_club_tournaments(
club_code=club_code,
club_name=club_name,
start_date=start_date_str,
end_date=end_date_str,
page=0
)
# Debug: Show what we got (only in verbose mode)
if verbose:
self.stdout.write(f"🔍 Raw result: {result}")
if result:
tournaments = result.get('tournaments', [])
self.stdout.write(self.style.SUCCESS(f"✅ SUCCESS: {len(tournaments)} tournaments found"))
if tournaments:
self.stdout.write("\n📝 Sample tournament:")
sample = tournaments[0]
self.stdout.write(f" ID: {sample.get('id')}")
self.stdout.write(f" Name: {sample.get('libelle')}")
self.stdout.write(f" Date: {sample.get('dateDebut', {}).get('date', 'N/A')}")
self.stdout.write(f" Judge: {sample.get('jugeArbitre', {}).get('nom', 'N/A')}")
self.stdout.write(f"\n📊 Summary:")
self.stdout.write(f" Total tournaments: {len(tournaments)}")
self.stdout.write(f" Pages scraped: {result.get('pages_scraped', 1)}")
else:
self.stdout.write(self.style.ERROR("❌ FAILED: No tournaments found"))
except Exception as e:
self.stdout.write(self.style.ERROR(f"❌ ERROR: {e}"))
import traceback
if verbose:
self.stdout.write(traceback.format_exc())

@ -0,0 +1,53 @@
from django.urls import reverse
from django.utils import timezone
import datetime
class ReferrerMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Check if the user is anonymous and going to the login page
if not request.user.is_authenticated and request.path == reverse('login'):
# Get the referring URL from the HTTP_REFERER header
referrer = request.META.get('HTTP_REFERER')
# Only store referrer if it exists and is not the login page itself
if referrer and 'login' not in referrer:
request.session['login_referrer'] = referrer
response = self.get_response(request)
return response
class RegistrationCartCleanupMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
self._check_and_clean_expired_cart(request)
response = self.get_response(request)
return response
def _check_and_clean_expired_cart(self, request):
if 'registration_cart_expiry' in request.session:
try:
expiry_str = request.session['registration_cart_expiry']
expiry = datetime.datetime.fromisoformat(expiry_str)
if timezone.now() > expiry:
# Clear expired cart
keys_to_delete = [
'registration_cart_id',
'registration_tournament_id',
'registration_cart_players',
'registration_cart_expiry',
'registration_mobile_number'
]
for key in keys_to_delete:
if key in request.session:
del request.session[key]
request.session.modified = True
except (ValueError, TypeError):
# Invalid expiry format, clear it
if 'registration_cart_expiry' in request.session:
del request.session['registration_cart_expiry']
request.session.modified = True

@ -1,18 +0,0 @@
# Generated by Django 5.1 on 2025-06-23 16:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0128_club_data_access_ids_court_data_access_ids_and_more'),
]
operations = [
migrations.AddField(
model_name='tournament',
name='currency_code',
field=models.CharField(blank=True, default='EUR', max_length=3, null=True),
),
]

@ -1,28 +0,0 @@
# Generated by Django 5.1 on 2025-06-25 14:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0129_tournament_currency_code'),
]
operations = [
migrations.AddField(
model_name='playerregistration',
name='contact_email',
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.AddField(
model_name='playerregistration',
name='contact_name',
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.AddField(
model_name='playerregistration',
name='contact_phone_number',
field=models.CharField(blank=True, max_length=50, null=True),
),
]

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

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

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

Loading…
Cancel
Save