diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py index 31e51fb..4859194 100644 --- a/padelclub_backend/settings.py +++ b/padelclub_backend/settings.py @@ -168,6 +168,9 @@ 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, @@ -196,7 +199,7 @@ LOGGING = { 'rotating_file': { 'level': 'INFO', 'class': 'logging.handlers.RotatingFileHandler', - 'filename': os.path.join(BASE_DIR, 'django.log'), + 'filename': os.path.join(LOGS_DIR, 'django.log'), 'maxBytes': 10 * 1024 * 1024, 'backupCount': 10, 'formatter': 'verbose', diff --git a/shop/admin.py b/shop/admin.py index bce87cf..a3febdc 100644 --- a/shop/admin.py +++ b/shop/admin.py @@ -4,12 +4,72 @@ 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") @@ -131,6 +191,57 @@ 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: @@ -194,9 +305,10 @@ class OrderAdmin(admin.ModelAdmin): def get_urls(self): urls = super().get_urls() custom_urls = [ - path('prepare-all-orders/', self.admin_site.admin_view(self.prepare_all_orders), name='prepare_all_orders'), - path('prepare-order//', self.admin_site.admin_view(self.prepare_order), name='prepare_order'), - path('cancel-and-refund-order//', self.admin_site.admin_view(self.cancel_and_refund_order), name='cancel_and_refund_order'), + 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('/prepare/', self.admin_site.admin_view(self.prepare_order), name='prepare_order'), + path('/cancel-refund/', self.admin_site.admin_view(self.cancel_and_refund_order), name='cancel_and_refund_order'), ] return custom_urls + urls diff --git a/shop/templates/admin/shop/dashboard.html b/shop/templates/admin/shop/dashboard.html new file mode 100644 index 0000000..b8b3a5d --- /dev/null +++ b/shop/templates/admin/shop/dashboard.html @@ -0,0 +1,163 @@ +{% extends "admin/base_site.html" %} +{% load admin_urls %} + +{% block title %}Shop Dashboard{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+ + +
+

Orders by Status

+
+ {% for status_data in order_status_data %} +
+ {{ status_data.label }} +
+ + {{ status_data.count }} + + + €{{ status_data.total_amount|floatformat:2 }} + +
+
+ {% endfor %} +
+
+ + +
+

Total Summary

+
+
+
{{ total_orders }}
+
Total Orders
+
+
+
€{{ total_revenue|floatformat:2 }}
+
Total Revenue
+
+
+
+ + +
+

Recent Activity

+
+
+
{{ orders_today }}
+
Orders Today
+
+
+
{{ orders_this_week }}
+
Orders This Week
+
+
+
{{ orders_this_month }}
+
Orders This Month
+
+
+
+ + + +
+ + +
+

Status Breakdown

+
+ + + + + + + + + + + + {% for status_data in order_status_data %} + + + + + + + + {% endfor %} + +
StatusCountPercentageTotal ValueAvg Order Value
{{ status_data.label }} + + {{ status_data.count }} + + {{ status_data.percentage|floatformat:1 }}% + €{{ status_data.total_amount|floatformat:2 }} + + {% if status_data.count > 0 %} + €{{ status_data.avg_order_value|floatformat:2 }} + {% else %} + €0.00 + {% endif %} +
+
+
+
+ + +{% endblock %} diff --git a/shop/templates/admin/shop/order/change_list.html b/shop/templates/admin/shop/order/change_list.html index e3e7fdc..f5ccfbc 100644 --- a/shop/templates/admin/shop/order/change_list.html +++ b/shop/templates/admin/shop/order/change_list.html @@ -2,6 +2,11 @@ {% block object-tools %}
    +
  • + + 📊 Dashboard + +
  • Orders to Prepare ({{ paid_orders_count }}) diff --git a/shop/templates/shop/partials/navigation_base.html b/shop/templates/shop/partials/navigation_base.html index f5dc311..351b98f 100644 --- a/shop/templates/shop/partials/navigation_base.html +++ b/shop/templates/shop/partials/navigation_base.html @@ -9,4 +9,8 @@ Se connecter {% endif %} La boutique + {% if user.is_authenticated and user.is_staff %} + Tableau de bord boutique + Préparer commandes + {% endif %} diff --git a/sync/admin.py b/sync/admin.py index 6134987..263d9cb 100644 --- a/sync/admin.py +++ b/sync/admin.py @@ -4,6 +4,9 @@ from django.utils import timezone from .models import BaseModel, ModelLog, DataAccess class SyncedObjectAdmin(admin.ModelAdmin): + + exclude = ('data_access_ids',) + def save_model(self, request, obj, form, change): if isinstance(obj, BaseModel): obj.last_updated_by = request.user diff --git a/sync/models/data_access.py b/sync/models/data_access.py index e4ba81d..5a38ae3 100644 --- a/sync/models/data_access.py +++ b/sync/models/data_access.py @@ -8,7 +8,9 @@ from ..registry import model_registry import uuid from . import ModelLog, SideStoreModel, BaseModel +import logging +logger = logging.getLogger(__name__) class DataAccess(BaseModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4) @@ -40,6 +42,9 @@ class DataAccess(BaseModel): 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() @@ -55,9 +60,10 @@ class DataAccess(BaseModel): store_id=store_id ) except ObjectDoesNotExist: + logger.warn(f'!!! object does not exists any more: {self.model_name} : {self.model_id} : {operation}') pass else: - print(f'model not found: {self.model_name}') + logger.warn(f'!!!model not found: {self.model_name}') def add_references(self): model_class = model_registry.get_model(self.model_name) diff --git a/sync/signals.py b/sync/signals.py index 9ce0b59..e0c0fba 100644 --- a/sync/signals.py +++ b/sync/signals.py @@ -6,11 +6,14 @@ 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 +import logging + +logger = logging.getLogger(__name__) + User = get_user_model() ### Sync @@ -28,12 +31,18 @@ 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) + sig_type = 'pre_save' + else: + sig_type = 'pre_delete' + logger.info(f'* {sig_type} : {instance.__class__.__name__} > impacted users = {users}') + @receiver([post_save, post_delete]) def synchronization_notifications(sender, instance, created=False, **kwargs): @@ -87,7 +96,14 @@ def notify_impacted_users(instance): def save_model_log_if_possible(instance, signal, created): users = related_users_registry.get_users(instance.id) - # print(f'*** save_model_log >>> users = {users}, instance = {instance}') + logger.debug(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}') + if users: if signal == post_save or signal == pre_save: if created: @@ -111,29 +127,43 @@ def save_model_log_if_possible(instance, signal, created): save_model_log(users, operation, model_name, instance.id, store_id) else: - print(f'>>> Model Log could not be created because no linked user could be found: {instance.__class__.__name__} {instance}, {signal}') + logger.info(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) - # print(f'>> creating Model Log for: {model_operation} {model_name}') - - logs_to_create = [ - ModelLog( - user=user, - operation=model_operation, - model_name=model_name, - model_id=model_id, - store_id=store_id, - device_id=device_id - ) - for user in users - if user.can_synchronize - ] + logger.info(f'*** creating ModelLogs for: {model_operation} {model_name} : {users}') + + try: + with transaction.atomic(): + created_logs = [] + for user in users: + logger.debug(f'Creating ModelLog for user {user.id} ({user.username})') + model_log = ModelLog( + user=user, + operation=model_operation, + model_name=model_name, + model_id=model_id, + store_id=store_id, + device_id=device_id + ) + model_log.save() + created_logs.append(model_log.id) + logger.debug(f'Successfully created ModelLog {model_log.id}') + + logger.info(f'*** Successfully created {len(created_logs)} ModelLogs: {created_logs}') + + # Verify ModelLogs were actually persisted + persisted_count = ModelLog.objects.filter(id__in=created_logs).count() + if persisted_count != len(created_logs): + logger.error(f'*** PERSISTENCE VERIFICATION FAILED! Created {len(created_logs)} ModelLogs but only {persisted_count} were persisted to database') + else: + logger.debug(f'*** PERSISTENCE VERIFIED: All {persisted_count} ModelLogs successfully persisted') - with transaction.atomic(): - ModelLog.objects.bulk_create(logs_to_create) + except Exception as e: + logger.error(f'*** FAILED to create ModelLogs for: {model_operation} {model_name}, users: {[u.id for u in users]}, error: {e}', exc_info=True) + raise # with transaction.atomic(): # for user in users: @@ -258,10 +288,11 @@ 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) - if action == "post_add": - instance.create_access_log(users, 'SHARED_ACCESS') - elif action == "post_remove": - instance.create_access_log(users, 'REVOKED_ACCESS') + 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) websocket_sender.send_message(pk_set, device_id) @@ -302,18 +333,8 @@ 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) diff --git a/tournaments/admin.py b/tournaments/admin.py index 6be27c2..3b67f56 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -3,8 +3,11 @@ 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 +from django.urls import reverse, path # Add path import from django.utils.safestring import mark_safe +from django.shortcuts import render # Add this import +from django.db.models import Sum, Count, Avg, Q # Add these imports +from datetime import datetime, timedelta # Add this import 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 @@ -21,15 +24,19 @@ class CustomUserAdmin(UserAdmin): 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'] ordering = ['-date_joined'] + raw_id_fields = ['agents'] fieldsets = [ - (None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active', 'registration_payment_mode', - 'clubs', 'country', 'phone', 'licence_id', 'umpire_code', + (None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active']}), + ('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': [ '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', 'groups', 'origin', 'agents', 'should_synchronize' + 'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'origin', 'agents', 'should_synchronize', 'can_synchronize' ]}), - ] + ] + add_fieldsets = [ ( None, @@ -85,6 +92,205 @@ class TournamentAdmin(SyncedObjectAdmin): ordering = ['-start_date'] search_fields = ['id'] + 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 + + 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_users = CustomUser.objects.all().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), + + # 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_users': recent_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 + class TeamRegistrationAdmin(SyncedObjectAdmin): list_display = ['player_names', 'group_stage', 'name', 'tournament', 'registration_date'] list_filter = [SimpleTournamentListFilter] @@ -93,7 +299,7 @@ class TeamRegistrationAdmin(SyncedObjectAdmin): class TeamScoreAdmin(SyncedObjectAdmin): list_display = ['team_registration', 'score', 'walk_out', 'match'] list_filter = [TeamScoreTournamentListFilter] - search_fields = ['id'] + search_fields = ['id', 'team_registration__player_registrations__first_name', 'team_registration__player_registrations__last_name'] raw_id_fields = ['team_registration', 'match'] # Add this line list_per_page = 50 # Controls pagination on the list view diff --git a/tournaments/custom_views.py b/tournaments/custom_views.py index 6469c21..d3503dc 100644 --- a/tournaments/custom_views.py +++ b/tournaments/custom_views.py @@ -31,6 +31,17 @@ 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): # Clear any potential password reset session data keys_to_clear = [key for key in request.session.keys() @@ -38,6 +49,10 @@ class CustomLoginView(auth_views.LoginView): 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 diff --git a/tournaments/forms.py b/tournaments/forms.py index 94578d2..2ca881d 100644 --- a/tournaments/forms.py +++ b/tournaments/forms.py @@ -338,29 +338,52 @@ 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 diff --git a/tournaments/models/match.py b/tournaments/models/match.py index 044bba1..25f0a15 100644 --- a/tournaments/models/match.py +++ b/tournaments/models/match.py @@ -307,7 +307,7 @@ class Match(TournamentSubModel): return self.start_date.astimezone(timezone) def local_planned_start_date(self): - timezone = self.tournament().timezone() + timezone = self.get_tournament().timezone() return self.planned_start_date.astimezone(timezone) def formatted_start_date(self): diff --git a/tournaments/models/tournament.py b/tournaments/models/tournament.py index c9868ac..86c380d 100644 --- a/tournaments/models/tournament.py +++ b/tournaments/models/tournament.py @@ -594,7 +594,7 @@ class Tournament(BaseModel): first_match = min(valid_matches, key=lambda match: match.start_date) # Format the date - timezone = first_match.tournament().timezone() + timezone = first_match.get_tournament().timezone() local_start = first_match.start_date.astimezone(timezone) time_format = 'l d M' formatted_schedule = f" - {formats.date_format(local_start, format=time_format)}" diff --git a/tournaments/templates/admin/tournaments/dashboard.html b/tournaments/templates/admin/tournaments/dashboard.html new file mode 100644 index 0000000..b6f6fb1 --- /dev/null +++ b/tournaments/templates/admin/tournaments/dashboard.html @@ -0,0 +1,446 @@ +{% extends "admin/base_site.html" %} +{% load admin_urls %} + +{% block title %}Tournament Dashboard{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +

    🏆 Tournament Dashboard

    + + +
    + + +
    +

    + 🎾 Starting Tournaments +

    +
    +
    +
    +
    {{ tournaments_today_total }}
    +
    Today
    +
    +
    +
    {{ tournaments_today_private }}/{{ tournaments_today_public }}
    +
    Private/Public
    +
    +
    +
    +
    +
    {{ tournaments_week_total }}
    +
    This Week
    +
    +
    +
    {{ tournaments_week_private }}/{{ tournaments_week_public }}
    +
    Private/Public
    +
    +
    +
    +
    +
    {{ tournaments_month_total }}
    +
    This Month
    +
    +
    +
    {{ tournaments_month_private }}/{{ tournaments_month_public }}
    +
    Private/Public
    +
    +
    +
    +
    + + +
    +

    + 🏁 Ended Tournaments +

    +
    +
    +
    {{ tournaments_ended_today }}
    +
    Today
    +
    +
    +
    {{ tournaments_ended_week }}
    +
    This Week
    +
    +
    +
    {{ tournaments_ended_month }}
    +
    This Month
    +
    +
    +
    {{ tournaments_ended_all }}
    +
    All Time
    +
    +
    +
    + + +
    +

    + 👥 Participants +

    +
    +
    +
    {{ total_teams }}
    +
    Total Teams
    +
    +
    +
    {{ total_players }}
    +
    Total Players
    +
    +
    +
    {{ avg_teams_per_tournament }}
    +
    Avg Teams/Tournament
    +
    +
    +
    + + +
    +

    + 🏓 Matches +

    +
    +
    +
    {{ total_matches }}
    +
    Total Matches
    +
    +
    +
    {{ matches_played }}
    +
    Played
    +
    +
    +
    {{ matches_pending }}
    +
    Pending
    +
    +
    +
    +
    + + +
    +

    + 👤 User Statistics +

    +
    + +
    +

    Total Users: {{ total_users }}

    +
    + Admin: + {{ users_admin }} +
    +
    + Site: + {{ users_site }} +
    +
    + App: + {{ users_app }} +
    +
    +
    + {% if total_users > 0 %} +
    +
    +
    + {% endif %} +
    +
    + Admin + Site + App +
    +
    +
    + + +
    +

    New Registrations

    +
    + Today: +
    {{ users_today }}
    +
    +
    + This Week: +
    {{ users_this_week }}
    +
    +
    + This Month: +
    {{ users_this_month }}
    +
    +
    + + +
    +

    + Recently Registered Users + + View All Users + +

    +
    + + + + + + + + + + + {% for user in recent_users %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    NameEmailOriginDate Joined
    {{ user.first_name }} {{ user.last_name }}{{ user.email }} + {% if user.origin == 0 %} + Admin + {% elif user.origin == 1 %} + Site + {% elif user.origin == 2 %} + App + {% else %} + Unknown + {% endif %} + {{ user.date_joined|date:"M d, Y H:i" }}
    No recent users found.
    +
    +
    +
    +
    + + +
    +

    + 💰 Purchase Statistics +

    +
    + +
    +

    Total Purchases: {{ total_purchases }}

    +
    + Today: +
    {{ purchases_today }}
    +
    +
    + This Week: +
    {{ purchases_this_week }}
    +
    +
    + This Month: +
    {{ purchases_this_month }}
    +
    +
    + + +
    +

    Recent Purchases

    +
    + + + + + + + + + + + + {% for purchase in recent_purchases %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    IDUserProductQuantityDate
    {{ purchase.id }}{{ purchase.user.email }}{{ purchase.product_id }}{{ purchase.quantity }}{{ purchase.purchase_date|date:"M d, Y H:i" }}
    No recent purchases found.
    +
    +
    +
    +
    + + +
    +

    + 📊 All Time Overview +

    +
    +
    +
    {{ tournaments_all_total }}
    +
    Total Tournaments
    +
    + {{ tournaments_all_private }} Private | {{ tournaments_all_public }} Public +
    +
    +
    +
    {{ tournaments_with_online_reg }}
    +
    Online Registration
    +
    +
    +
    {{ tournaments_with_payment }}
    +
    Online Payment
    +
    +
    +
    €{{ avg_entry_fee }}
    +
    Avg Entry Fee
    +
    +
    +
    + + +
    +

    📈 Tournament Breakdown

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PeriodRunningPrivatePublicEnded
    Today + + {{ tournaments_today_total }} + + {{ tournaments_today_private }}{{ tournaments_today_public }}{{ tournaments_ended_today }}
    This Week + + {{ tournaments_week_total }} + + {{ tournaments_week_private }}{{ tournaments_week_public }}{{ tournaments_ended_week }}
    This Month + + {{ tournaments_month_total }} + + {{ tournaments_month_private }}{{ tournaments_month_public }}{{ tournaments_ended_month }}
    All Time + + {{ tournaments_all_total }} + + {{ tournaments_all_private }}{{ tournaments_all_public }}{{ tournaments_ended_all }}
    +
    +
    + + + +
    + + +{% endblock %} diff --git a/tournaments/templates/admin/tournaments/tournament/change_list.html b/tournaments/templates/admin/tournaments/tournament/change_list.html new file mode 100644 index 0000000..28ea9b6 --- /dev/null +++ b/tournaments/templates/admin/tournaments/tournament/change_list.html @@ -0,0 +1,18 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools %} + +{% endblock %} diff --git a/tournaments/templates/registration/login.html b/tournaments/templates/registration/login.html index d4ac8e7..6bde7f3 100644 --- a/tournaments/templates/registration/login.html +++ b/tournaments/templates/registration/login.html @@ -13,11 +13,9 @@
    {% if form.non_field_errors %}
    - {% if form.non_field_errors %} - {% for error in form.non_field_errors %} -

    {{ error }}

    - {% endfor %} - {% endif %} + {% for error in form.non_field_errors %} +

    {{ error }}

    + {% endfor %} {% for field in form %} {% for error in field.errors %} @@ -26,6 +24,30 @@ {% endfor %}
    {% endif %} + + + {% if inactive_user_email %} +
    +

    Besoin d'aide ?

    +

    Si vous n'avez pas reçu l'e-mail d'activation ou si le lien a expiré, vous pouvez en demander un nouveau :

    + +
    + {% csrf_token %} + + {% if request.GET.next %} + + {% endif %} + +
    + +

    + Le lien sera envoyé à : {{ inactive_user_email }} +

    +
    + {% endif %} +
    {% csrf_token %} {% if request.GET.next and 'reset' not in request.GET.next and 'password_reset' not in request.GET.next %} diff --git a/tournaments/templates/registration/signup_success.html b/tournaments/templates/registration/signup_success.html index 1357f90..5c4fad0 100644 --- a/tournaments/templates/registration/signup_success.html +++ b/tournaments/templates/registration/signup_success.html @@ -20,6 +20,27 @@
  • Vérifier votre boîte de réception (et vos spams si nécessaire)
  • Cliquer sur le lien de confirmation dans l'e-mail
  • + + +
    +

    Vous n'avez pas reçu l'e-mail ?

    +

    Si l'e-mail de confirmation n'arrive pas dans les prochaines minutes, vous pouvez en demander un nouveau :

    + + + {% csrf_token %} + + {% if next_url %} + + {% endif %} + + + +

    + Le lien sera envoyé à : {{ user_email }} +

    +
    Continuer diff --git a/tournaments/templates/tournaments/navigation_base.html b/tournaments/templates/tournaments/navigation_base.html index b1ef0ea..7e07f5e 100644 --- a/tournaments/templates/tournaments/navigation_base.html +++ b/tournaments/templates/tournaments/navigation_base.html @@ -1,14 +1,18 @@ - - + diff --git a/tournaments/templates/tournaments/navigation_tournament.html b/tournaments/templates/tournaments/navigation_tournament.html index 7f74a36..4af9993 100644 --- a/tournaments/templates/tournaments/navigation_tournament.html +++ b/tournaments/templates/tournaments/navigation_tournament.html @@ -37,4 +37,35 @@ {% else %} Se connecter {% endif %} + + + {% if user.is_superuser %} +
    + {% csrf_token %} + +
    + {% endif %} diff --git a/tournaments/urls.py b/tournaments/urls.py index 6867a7c..7b400c3 100644 --- a/tournaments/urls.py +++ b/tournaments/urls.py @@ -60,7 +60,7 @@ urlpatterns = [ path('logout/', views.custom_logout, name='custom_logout'), path('utils/xls-to-csv/', views.xls_to_csv, name='xls-to-csv'), path('signup/', views.signup, name='signup'), # URL pattern for signup -# path('profile/', views.profile, name='profile'), # URL pattern for signup + path('resend-activation/', views.resend_activation_email, name='resend-activation'), path('my-tournaments/', views.my_tournaments, name='my-tournaments'), # URL pattern for signup path('all_my_ended_tournaments/', views.all_my_ended_tournaments, name='all-my-ended-tournaments'), # URL pattern for signup path('tournaments//cancel-registration/', views.cancel_registration, name='cancel_registration'), @@ -80,4 +80,7 @@ urlpatterns = [ path('tournaments//confirm/', views.confirm_tournament_registration, name='confirm_tournament_registration'), path('stripe-onboarding-complete/', views.stripe_onboarding_complete, name='stripe-onboarding-complete'), path('stripe-refresh-account-link/', views.stripe_refresh_account_link, name='stripe-refresh-account-link'), + path('tournaments//toggle-private/', views.toggle_tournament_private, name='toggle_tournament_private'), + path("private-tournaments/", views.private_tournaments, name="private-tournaments"), + ] diff --git a/tournaments/views.py b/tournaments/views.py index 0db52eb..c9ff7e9 100644 --- a/tournaments/views.py +++ b/tournaments/views.py @@ -730,6 +730,43 @@ def send_verification_email(request, user, next_url): email.content_subtype = "html" email.send() +def resend_activation_email(request): + """View to resend activation email for inactive users.""" + if request.method == 'POST': + username_or_email = request.POST.get('username_or_email', '').strip() + + if not username_or_email: + messages.error(request, 'Veuillez fournir un nom d\'utilisateur ou un e-mail.') + return redirect('custom-login') + + # Try to find the user + user = None + try: + # Try by username first + user = CustomUser.objects.get(username__iexact=username_or_email) + except CustomUser.DoesNotExist: + try: + # Try by email + user = CustomUser.objects.get(email__iexact=username_or_email) + except CustomUser.DoesNotExist: + messages.error(request, 'Aucun compte trouvé avec cet identifiant.') + return redirect('custom-login') + + # Check if user is already active + if user.is_active: + messages.info(request, 'Votre compte est déjà activé. Vous pouvez vous connecter.') + return redirect('custom-login') + + # Send the activation email + next_url = request.POST.get('next', '') + send_verification_email(request, user, next_url) + + messages.success(request, f'Un nouveau lien d\'activation a été envoyé à {user.email}.') + return redirect('custom-login') + + # If GET request, redirect to login + return redirect('custom-login') + @login_required def profile(request): user = request.user # Get the currently authenticated user @@ -1622,6 +1659,122 @@ def stripe_onboarding_complete(request): def stripe_refresh_account_link(request): return render(request, 'stripe/refresh_account_link.html') +def toggle_tournament_private(request, tournament_id): + """Toggle tournament privacy status (for superusers only)""" + + # Check if user is superuser + if not request.user.is_superuser: + if request.headers.get('Content-Type') == 'application/json': + return JsonResponse({'error': 'Accès non autorisé'}, status=403) + messages.error(request, 'Accès non autorisé') + return redirect('tournament-info', tournament_id=tournament_id) + + # Only allow POST requests + if request.method != 'POST': + if request.headers.get('Content-Type') == 'application/json': + return JsonResponse({'error': 'Méthode non autorisée'}, status=405) + messages.error(request, 'Méthode non autorisée') + return redirect('tournament-info', tournament_id=tournament_id) + + try: + tournament = get_object_or_404(Tournament, pk=tournament_id) + + # Toggle the private status + tournament.is_private = not tournament.is_private + tournament.save() + + # Check if this is an AJAX request + if request.headers.get('Content-Type') == 'application/json': + return JsonResponse({ + 'success': True, + 'is_private': tournament.is_private, + 'message': f'Tournoi défini comme {"privé" if tournament.is_private else "public"}' + }) + else: + # Regular form submission - add success message and redirect + status = "privé" if tournament.is_private else "public" + messages.success(request, f'Tournoi défini comme {status}') + return redirect('tournament-info', tournament_id=tournament_id) + + except Exception as e: + if request.headers.get('Content-Type') == 'application/json': + return JsonResponse({'error': f'Erreur: {str(e)}'}, status=500) + messages.error(request, f'Erreur: {str(e)}') + return redirect('tournament-info', tournament_id=tournament_id) + +def private_tournaments(request): + """ + View for displaying private tournaments (staff-only). + Similar to index, but shows private tournaments instead. + """ + if not request.user.is_staff: + messages.error(request, 'Accès non autorisé') + return redirect('index') + + now = timezone.now() + thirty_days_ago = now - timedelta(days=30) + thirty_days_future = now + timedelta(days=30) + + # Define a custom private tournaments query function + def private_tournaments_query(query, ascending, limit=None): + queries = [query, Q(is_private=True, is_deleted=False, event__club__isnull=False)] + + sortkey = 'start_date' + if not ascending: + sortkey = '-start_date' + + queryset = Tournament.objects.filter(*queries).prefetch_related( + 'group_stages', + 'rounds', + 'team_registrations', + ).order_by(sortkey) + + # Apply limit directly in the database query + if limit is not None and isinstance(limit, int) and limit > 0: + queryset = queryset[:limit] + + return queryset + + # Get all tournaments matching our criteria (similar to index but for private tournaments) + tournaments = private_tournaments_query( + Q(end_date__isnull=True, start_date__gte=thirty_days_ago, start_date__lte=thirty_days_future), + True, 50 + ) + + # Filter tournaments that should be displayed + display_tournament = tournaments + + # Categorize tournaments by status + live = [] + future = [] + for t in display_tournament: + if t.supposedly_in_progress(): + live.append(t) + elif t.starts_in_the_future(): + future.append(t) + + # Get ended tournaments + clean_ended_tournaments = private_tournaments_query(Q(end_date__isnull=False), False, 50) + clean_ended_tournaments = [t for t in clean_ended_tournaments if t.display_tournament()] + ended_tournaments = [t for t in display_tournament if t.should_be_over()] + + # Combine both lists + finished = clean_ended_tournaments + ended_tournaments + + # Sort the combined list by start_date in descending order + finished.sort(key=lambda t: t.sorting_finished_date(), reverse=True) + + return render( + request, + "tournaments/tournaments.html", + { + 'future': future[:10], + 'live': live[:10], + 'ended': finished[:10], + 'is_private_section': True, # Flag to indicate we're in the private tournaments section + 'section_title': 'Tournois privés', # Title for the private tournaments page + } + ) class UserListExportView(LoginRequiredMixin, View): def get(self, request, *args, **kwargs):