From 08ada3d771f75859ca88b62d4a1a3705ca05637c Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 17 Sep 2025 11:04:09 +0200 Subject: [PATCH 1/5] adds api keys for api usage --- api/__init__.py | 0 api/admin.py | 21 ++++++++++++++++++++ api/apps.py | 7 +++++++ api/authentication.py | 24 +++++++++++++++++++++++ api/migrations/0001_initial.py | 36 ++++++++++++++++++++++++++++++++++ api/migrations/__init__.py | 0 api/models.py | 23 ++++++++++++++++++++++ api/views.py | 4 +++- padelclub_backend/settings.py | 2 ++ requirements.txt | 1 + 10 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 api/__init__.py create mode 100644 api/admin.py create mode 100644 api/apps.py create mode 100644 api/authentication.py create mode 100644 api/migrations/0001_initial.py create mode 100644 api/migrations/__init__.py create mode 100644 api/models.py diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/admin.py b/api/admin.py new file mode 100644 index 0000000..e09b9df --- /dev/null +++ b/api/admin.py @@ -0,0 +1,21 @@ +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"] + + 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 \ No newline at end of file diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..a5ed1ab --- /dev/null +++ b/api/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api' + verbose_name = 'API' \ No newline at end of file diff --git a/api/authentication.py b/api/authentication.py new file mode 100644 index 0000000..2f225b5 --- /dev/null +++ b/api/authentication.py @@ -0,0 +1,24 @@ +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 diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py new file mode 100644 index 0000000..cc1e140 --- /dev/null +++ b/api/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# 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, + }, + ), + ] diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..4ce9e1d --- /dev/null +++ b/api/models.py @@ -0,0 +1,23 @@ +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}" \ No newline at end of file diff --git a/api/views.py b/api/views.py index 6f9a37a..4778afb 100644 --- a/api/views.py +++ b/api/views.py @@ -4,6 +4,7 @@ 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 @@ -76,10 +77,11 @@ class ClubViewSet(SoftDeleteViewSet): class TournamentSummaryViewSet(SoftDeleteViewSet): queryset = Tournament.objects.all() serializer_class = TournamentSummarySerializer + permission_classes = [HasAPIKey] def get_queryset(self): if self.request.user.is_anonymous: - return [] + return Tournament.objects.none() return self.queryset.filter( Q(event__creator=self.request.user) | Q(related_user=self.request.user) diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py index 28ad40d..eab2309 100644 --- a/padelclub_backend/settings.py +++ b/padelclub_backend/settings.py @@ -37,6 +37,7 @@ INSTALLED_APPS = [ 'tournaments', 'shop', 'biz', + 'api', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -50,6 +51,7 @@ INSTALLED_APPS = [ 'channels_redis', 'django_filters', 'background_task', + 'rest_framework_api_key', ] diff --git a/requirements.txt b/requirements.txt index 892df2f..9a55a1d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ stripe==11.6.0 django-background-tasks==1.2.8 Pillow==10.2.0 playwright==1.40.0 +djangorestframework-api-key==3.1.0 From bc792ed47047cf9edc8a906d404b384376f8701f Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 17 Sep 2025 11:39:42 +0200 Subject: [PATCH 2/5] make user a raw_id field --- api/admin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/admin.py b/api/admin.py index e09b9df..9dfdf6c 100644 --- a/api/admin.py +++ b/api/admin.py @@ -12,10 +12,11 @@ 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 \ No newline at end of file + return form From 42bdb3bfed9c5d86c44943ad62ccc0e2c8e06071 Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 17 Sep 2025 17:16:57 +0200 Subject: [PATCH 3/5] Add filter for user that have created an event --- tournaments/admin.py | 4 ++-- tournaments/filters.py | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/tournaments/admin.py b/tournaments/admin.py index 1ee20d0..c6926b0 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -11,7 +11,7 @@ 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 -from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter +from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter, UserWithEventsFilter from sync.admin import SyncedObjectAdmin @@ -23,7 +23,7 @@ class CustomUserAdmin(UserAdmin): filter_horizontal = ('clubs',) 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'] + list_filter = ['is_active', 'origin', UserWithEventsFilter] ordering = ['-date_joined'] raw_id_fields = ['agents'] fieldsets = [ diff --git a/tournaments/filters.py b/tournaments/filters.py index 5d99d79..aa1e8af 100644 --- a/tournaments/filters.py +++ b/tournaments/filters.py @@ -1,6 +1,6 @@ from django.contrib import admin from .models import Tournament, Match -from django.db.models import Q +from django.db.models import Q, Count from django.utils.translation import gettext_lazy as _ from django.utils import timezone from datetime import timedelta @@ -135,3 +135,20 @@ 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 From c4be3c9ce23232610f03a16ae4b42d37cdbe3811 Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 17 Sep 2025 17:23:05 +0200 Subject: [PATCH 4/5] add filter for user with/without purchases --- tournaments/admin.py | 4 ++-- tournaments/filters.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/tournaments/admin.py b/tournaments/admin.py index c6926b0..6cd3369 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -11,7 +11,7 @@ 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 -from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter, UserWithEventsFilter +from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter, UserWithEventsFilter, UserWithPurchasesFilter from sync.admin import SyncedObjectAdmin @@ -23,7 +23,7 @@ class CustomUserAdmin(UserAdmin): filter_horizontal = ('clubs',) 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] + list_filter = ['is_active', 'origin', UserWithEventsFilter, UserWithPurchasesFilter] ordering = ['-date_joined'] raw_id_fields = ['agents'] fieldsets = [ diff --git a/tournaments/filters.py b/tournaments/filters.py index aa1e8af..c75e49b 100644 --- a/tournaments/filters.py +++ b/tournaments/filters.py @@ -152,3 +152,20 @@ class UserWithEventsFilter(admin.SimpleListFilter): 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 From 10168de3cd16cf9ab5b597d8f700c2902b159cbe Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 17 Sep 2025 17:30:50 +0200 Subject: [PATCH 5/5] adds filter for user that have or dont have a prospect with the same email --- tournaments/admin.py | 4 ++-- tournaments/filters.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/tournaments/admin.py b/tournaments/admin.py index 6cd3369..5161543 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -11,7 +11,7 @@ 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 -from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter, UserWithEventsFilter, UserWithPurchasesFilter +from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter, UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter from sync.admin import SyncedObjectAdmin @@ -23,7 +23,7 @@ class CustomUserAdmin(UserAdmin): filter_horizontal = ('clubs',) 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] + list_filter = ['is_active', 'origin', UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter] ordering = ['-date_joined'] raw_id_fields = ['agents'] fieldsets = [ diff --git a/tournaments/filters.py b/tournaments/filters.py index c75e49b..f55cfbb 100644 --- a/tournaments/filters.py +++ b/tournaments/filters.py @@ -169,3 +169,23 @@ class UserWithPurchasesFilter(admin.SimpleListFilter): 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