diff --git a/.gitignore b/.gitignore index 4e82b22..2f99ee3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ padelclub_backend/settings_local.py myenv/ -tournaments/config_local.py +shared/config_local.py # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/api/serializers.py b/api/serializers.py index ec03749..d5b44f3 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -9,9 +9,23 @@ from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode from django.utils.encoding import force_bytes from django.core.mail import EmailMessage from django.contrib.sites.shortcuts import get_current_site - from api.tokens import account_activation_token +from shared.cryptography import encryption_util + +class EncryptedUserField(serializers.Field): + def to_representation(self, value): + # Encrypt data when sending it out + return encryption_util.encrypt_aes_gcm(str(value.id)) + + def to_internal_value(self, data): + # Decrypt data when receiving it + decrypted_user_id = encryption_util.decrypt_aes_gcm(data) + user = CustomUser.objects.get(id=decrypted_user_id) + if decrypted_user_id is None: + raise serializers.ValidationError("Invalid encrypted data") + return user + class UserSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True) @@ -53,6 +67,7 @@ class UserSerializer(serializers.ModelSerializer): bracket_match_format_preference=validated_data.get('bracket_match_format_preference'), group_stage_match_format_preference=validated_data.get('group_stage_match_format_preference'), loser_bracket_match_format_preference=validated_data.get('loser_bracket_match_format_preference'), + loser_bracket_mode=validated_data.get('loser_bracket_mode'), ) self.send_email(self.context['request'], user) @@ -153,6 +168,8 @@ class PlayerRegistrationSerializer(serializers.ModelSerializer): # ['id', 'team_registration_id', 'first_name', 'last_name', 'licence_id', 'rank', 'has_paid'] class PurchaseSerializer(serializers.ModelSerializer): + user = EncryptedUserField() + class Meta: model = Purchase fields = '__all__' diff --git a/api/views.py b/api/views.py index b8d93d2..acda60c 100644 --- a/api/views.py +++ b/api/views.py @@ -107,8 +107,16 @@ class PurchaseViewSet(viewsets.ModelViewSet): return self.queryset.filter(user=self.request.user) return [] - def put(self, request, pk): - raise MethodNotAllowed('PUT') + def create(self, request, *args, **kwargs): + id = request.data.get('id') + if Purchase.objects.filter(id=id).exists(): + return Response({"detail": "This transaction id is already registered."}, status=status.HTTP_208_ALREADY_REPORTED) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def patch(self, request, pk): raise MethodNotAllowed('PATCH') diff --git a/shared/cryptography.py b/shared/cryptography.py new file mode 100644 index 0000000..1ca41d4 --- /dev/null +++ b/shared/cryptography.py @@ -0,0 +1,51 @@ +from Crypto.Cipher import AES +import base64 +import os +from .config_local import CRYPTO_KEY + +class EncryptionUtil: + + def __init__(self, key): + # In a real application, store this key securely (e.g., environment variables) + self.crypto_key = key + + def encrypt_aes_gcm(self, plaintext): + # Decode the base64 encoded key + key = base64.b64decode(self.crypto_key) + + # Generate a random 12-byte nonce + nonce = os.urandom(12) + + # Create the cipher object + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + + # Encrypt the plaintext + ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode('utf-8')) + + # Combine nonce, ciphertext, and tag + encrypted_data = nonce + ciphertext + tag + + # Encode the result in base64 + encrypted_base64 = base64.b64encode(encrypted_data).decode('utf-8') + + return encrypted_base64 + + def decrypt_aes_gcm(self, encrypted_base64): + # Decode the base64 encoded data and key + encrypted_data = base64.b64decode(encrypted_base64) + key = base64.b64decode(self.crypto_key) + + # Extract the nonce, tag, and ciphertext from the combined encrypted data + nonce = encrypted_data[:12] # AES GCM nonce is 12 bytes + tag = encrypted_data[-16:] # AES GCM tag is 16 bytes + ciphertext = encrypted_data[12:-16] # Ciphertext is everything in between + + # Create the cipher object and decrypt the data + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + decrypted_data = cipher.decrypt_and_verify(ciphertext, tag) + + # Convert decrypted bytes to string (assuming UTF-8 encoding) + decrypted_text = decrypted_data.decode('utf-8') + return decrypted_text + +encryption_util = EncryptionUtil(CRYPTO_KEY) diff --git a/tournaments/admin.py b/tournaments/admin.py index 3d14e54..a272866 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -23,7 +23,7 @@ class CustomUserAdmin(UserAdmin): '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_match_format_preference', 'device_id', 'loser_bracket_mode' ]}), ] add_fieldsets = [ @@ -60,6 +60,7 @@ class RoundAdmin(admin.ModelAdmin): class PlayerRegistrationAdmin(admin.ModelAdmin): list_display = ['first_name', 'last_name', 'licence_id', 'rank'] search_fields = ('first_name', 'last_name') + list_filter = [TeamScoreTournamentListFilter] class MatchAdmin(admin.ModelAdmin): list_display = ['__str__', 'round', 'group_stage', 'start_date', 'index'] @@ -74,7 +75,8 @@ class ClubAdmin(admin.ModelAdmin): search_fields = ('name', 'acronym') class PurchaseAdmin(admin.ModelAdmin): - list_display = ['user', 'identifier', 'product_id', 'quantity', 'purchase_date', 'revocation_date'] + list_display = ['id', 'user', 'product_id', 'quantity', 'purchase_date', 'revocation_date', 'expiration_date'] + list_filter = ['user'] class CourtAdmin(admin.ModelAdmin): list_display = ['index', 'name', 'club'] diff --git a/tournaments/migrations/0082_alter_purchase_identifier_and_more.py b/tournaments/migrations/0082_alter_purchase_identifier_and_more.py new file mode 100644 index 0000000..0310ea3 --- /dev/null +++ b/tournaments/migrations/0082_alter_purchase_identifier_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1 on 2024-09-16 08:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0081_round_group_stage_loser_bracket'), + ] + + operations = [ + migrations.AlterField( + model_name='purchase', + name='identifier', + field=models.BigIntegerField(unique=True), + ), + migrations.AlterField( + model_name='tournament', + name='federal_age_category', + field=models.IntegerField(choices=[(0, ''), (120, 'U12'), (140, 'U14'), (160, 'U16'), (180, 'U18'), (200, 'Senior'), (450, '+45 ans'), (550, '+55 ans')], default=200), + ), + ] diff --git a/tournaments/migrations/0083_purchase_expiration_date.py b/tournaments/migrations/0083_purchase_expiration_date.py new file mode 100644 index 0000000..d97750f --- /dev/null +++ b/tournaments/migrations/0083_purchase_expiration_date.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2024-09-16 13:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0082_alter_purchase_identifier_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='purchase', + name='expiration_date', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/tournaments/migrations/0084_customuser_loser_bracket_mode_and_more.py b/tournaments/migrations/0084_customuser_loser_bracket_mode_and_more.py new file mode 100644 index 0000000..8c6f69b --- /dev/null +++ b/tournaments/migrations/0084_customuser_loser_bracket_mode_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.11 on 2024-09-18 08:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0083_purchase_expiration_date'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='loser_bracket_mode', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='round', + name='loser_bracket_mode', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='tournament', + name='loser_bracket_mode', + field=models.IntegerField(default=0), + ), + ] diff --git a/tournaments/migrations/0084_remove_purchase_id_alter_purchase_identifier.py b/tournaments/migrations/0084_remove_purchase_id_alter_purchase_identifier.py new file mode 100644 index 0000000..36fcb03 --- /dev/null +++ b/tournaments/migrations/0084_remove_purchase_id_alter_purchase_identifier.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1 on 2024-09-18 08:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0083_purchase_expiration_date'), + ] + + operations = [ + migrations.RemoveField( + model_name='purchase', + name='id', + ), + migrations.AlterField( + model_name='purchase', + name='identifier', + field=models.BigIntegerField(primary_key=True, serialize=False, unique=True), + ), + ] diff --git a/tournaments/migrations/0085_rename_identifier_purchase_id.py b/tournaments/migrations/0085_rename_identifier_purchase_id.py new file mode 100644 index 0000000..2de4b5b --- /dev/null +++ b/tournaments/migrations/0085_rename_identifier_purchase_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2024-09-18 08:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0084_remove_purchase_id_alter_purchase_identifier'), + ] + + operations = [ + migrations.RenameField( + model_name='purchase', + old_name='identifier', + new_name='id', + ), + ] diff --git a/tournaments/migrations/0086_merge_20240919_1142.py b/tournaments/migrations/0086_merge_20240919_1142.py new file mode 100644 index 0000000..07e4b71 --- /dev/null +++ b/tournaments/migrations/0086_merge_20240919_1142.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1 on 2024-09-19 09:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0084_customuser_loser_bracket_mode_and_more'), + ('tournaments', '0085_rename_identifier_purchase_id'), + ] + + operations = [ + ] diff --git a/tournaments/models/custom_user.py b/tournaments/models/custom_user.py index a05a246..5f6ecdf 100644 --- a/tournaments/models/custom_user.py +++ b/tournaments/models/custom_user.py @@ -30,6 +30,7 @@ class CustomUser(AbstractUser): loser_bracket_match_format_preference = models.IntegerField(default=enums.FederalMatchCategory.NINE_GAMES, choices=enums.FederalMatchCategory.choices, null=True, blank=True) device_id = models.CharField(max_length=50, null=True, blank=True) + loser_bracket_mode = models.IntegerField(default=0) ### ### ### ### ### ### ### ### ### ### ### WARNING ### ### ### ### ### ### ### ### ### ### ### WARNING : Any added field MUST be inserted in the method below: fields_for_update() ### @@ -42,7 +43,7 @@ class CustomUser(AbstractUser): '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'] + 'group_stage_match_format_preference', 'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode'] def __str__(self): return self.username diff --git a/tournaments/models/purchase.py b/tournaments/models/purchase.py index 6dc72e4..014a15e 100644 --- a/tournaments/models/purchase.py +++ b/tournaments/models/purchase.py @@ -3,13 +3,13 @@ import uuid from . import CustomUser class Purchase(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + id = models.BigIntegerField(primary_key=True, unique=True, editable=True) user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) - identifier = models.BigIntegerField() purchase_date = models.DateTimeField() product_id = models.CharField(max_length=100) quantity = models.IntegerField(null=True, blank=True) revocation_date = models.DateTimeField(null=True, blank=True) + expiration_date = models.DateTimeField(null=True, blank=True) def __str__(self): - return f"{self.identifier} > {self.product_id} - {self.purchase_date} - {self.user.username}" + return f"{self.id} > {self.product_id} - {self.purchase_date} - {self.user.username}" diff --git a/tournaments/models/round.py b/tournaments/models/round.py index 3c5eb2e..4f5a348 100644 --- a/tournaments/models/round.py +++ b/tournaments/models/round.py @@ -10,6 +10,7 @@ class Round(models.Model): format = models.IntegerField(default=FederalMatchCategory.NINE_GAMES, choices=FederalMatchCategory.choices, null=True, blank=True) start_date = models.DateTimeField(null=True, blank=True) group_stage_loser_bracket = models.BooleanField(default=False) + loser_bracket_mode = models.IntegerField(default=0) def __str__(self): if self.parent: diff --git a/tournaments/models/tournament.py b/tournaments/models/tournament.py index 586105d..063a7a0 100644 --- a/tournaments/models/tournament.py +++ b/tournaments/models/tournament.py @@ -7,8 +7,7 @@ from . import Event, TournamentPayment, FederalMatchCategory, FederalCategory, F import uuid from django.utils import timezone, formats from datetime import datetime, timedelta -from .. import config_local -from ..utils.cryptography import decrypt_aes_gcm +from shared.cryptography import encryption_util from ..utils.extensions import plural_format class TeamSortingType(models.IntegerChoices): @@ -59,6 +58,7 @@ class Tournament(models.Model): publish_tournament = models.BooleanField(default=False) hide_points_earned = models.BooleanField(default=False) publish_rankings = models.BooleanField(default=False) + loser_bracket_mode = models.IntegerField(default=0) def __str__(self): if self.name: @@ -67,8 +67,8 @@ class Tournament(models.Model): return self.display_name() def is_canceled(self): - if self.local_id and config_local.CRYPTO_KEY: - decrypted = decrypt_aes_gcm(self.local_id, config_local.CRYPTO_KEY) + if self.local_id: + decrypted = encryption_util.decrypt_aes_gcm(self.local_id) value = int(decrypted[18]) if 0 <= value <= 4: return True @@ -78,8 +78,8 @@ class Tournament(models.Model): return False def payment(self): - if self.global_id and config_local.CRYPTO_KEY: - decrypted = decrypt_aes_gcm(self.global_id, config_local.CRYPTO_KEY) + if self.global_id: + decrypted = encryption_util.decrypt_aes_gcm(self.global_id) value = int(decrypted[18]) return TournamentPayment(value) else: @@ -91,6 +91,18 @@ class Tournament(models.Model): else: return self.base_name() + def broadcast_display_name(self): + if self.name: + return self.short_base_name() + " " + self.name + else: + return self.base_name() + + def broadcast_event_display_name(self): + if self.event is not None: + return self.event.display_name() + else: + return " " + def base_name(self): return f"{self.level()} {self.category()}" diff --git a/tournaments/signals.py b/tournaments/signals.py index ded9d54..a5ae266 100644 --- a/tournaments/signals.py +++ b/tournaments/signals.py @@ -8,7 +8,7 @@ from .models import Club, FailedApiCall, CustomUser, Log import requests def generate_unique_code(): - characters = string.ascii_letters + string.digits + characters = string.ascii_lowercase + string.digits while True: code = ''.join(random.sample(characters, 3)) if not Club.objects.filter(broadcast_code=code).exists(): diff --git a/tournaments/templates/tournaments/broadcast/broadcasted_auto.html b/tournaments/templates/tournaments/broadcast/broadcasted_auto.html index 74cdf64..6888cc7 100644 --- a/tournaments/templates/tournaments/broadcast/broadcasted_auto.html +++ b/tournaments/templates/tournaments/broadcast/broadcasted_auto.html @@ -37,7 +37,7 @@ paginatedGroupStages: null, paginatedSummons: null, active: 1, - title: '', + prefixTitle: '', retrieveData() { fetch('/tournament/{{ tournament.id }}/broadcast/json/') .then(res => res.json()) @@ -45,7 +45,7 @@ this.paginatedMatches = this.paginate(data.matches, 8) this.paginatedGroupStages = this.paginate(data.group_stages, 4) this.paginatedSummons = this.paginateSummons(data.summons) - this.setTitle() + this.setPrefixTitle() }) }, paginateSummons(array) { @@ -76,19 +76,19 @@ setInterval(() => { this.retrieveData() this.active = this.active === this.pageCount() ? 1 : this.active+1 - this.setTitle() + this.setPrefixTitle() }, 15000) }, pageCount() { return this.paginatedMatches.length + this.paginatedGroupStages.length + this.paginatedSummons.length }, - setTitle() { + setPrefixTitle() { if (this.active < 1 + this.paginatedSummons.length) { - this.title = 'Convocations' + this.prefixTitle = 'Convocations' } else if (this.active < 1 + this.paginatedSummons.length + this.paginatedMatches.length) { - this.title = 'Matchs' + this.prefixTitle = 'Matchs' } else { - this.title = 'Poules' + this.prefixTitle = 'Poules' } } @@ -104,8 +104,8 @@ class="logo inline" />
-

{{ tournament.display_name }}

-

+

{{ tournament.broadcast_event_display_name }}

+

{{ tournament.broadcast_display_name }}

diff --git a/tournaments/templates/tournaments/broadcast/broadcasted_group_stages.html b/tournaments/templates/tournaments/broadcast/broadcasted_group_stages.html index 7af7d40..69b7028 100644 --- a/tournaments/templates/tournaments/broadcast/broadcasted_group_stages.html +++ b/tournaments/templates/tournaments/broadcast/broadcasted_group_stages.html @@ -67,8 +67,8 @@
-

{{ tournament.display_name }}

-

Poules

+

{{ tournament.broadcast_event_display_name }}

+

Poules {{ tournament.broadcast_display_name }}

{% qr_from_text qr_code_url options=qr_code_options %}
diff --git a/tournaments/templates/tournaments/broadcast/broadcasted_matches.html b/tournaments/templates/tournaments/broadcast/broadcasted_matches.html index 9718077..57db0a0 100644 --- a/tournaments/templates/tournaments/broadcast/broadcasted_matches.html +++ b/tournaments/templates/tournaments/broadcast/broadcasted_matches.html @@ -67,8 +67,8 @@
-

{{ tournament.display_name }}

-

Matchs

+

{{ tournament.broadcast_event_display_name }}

+

Matchs {{ tournament.broadcast_display_name }}

{% qr_from_text qr_code_url options=qr_code_options %}
diff --git a/tournaments/templates/tournaments/broadcast/broadcasted_rankings.html b/tournaments/templates/tournaments/broadcast/broadcasted_rankings.html index 237d821..ed4ef95 100644 --- a/tournaments/templates/tournaments/broadcast/broadcasted_rankings.html +++ b/tournaments/templates/tournaments/broadcast/broadcasted_rankings.html @@ -1,8 +1,8 @@ {% extends 'tournaments/broadcast/broadcast_base.html' %} {% block head_title %}Classement{% endblock %} -{% block first_title %}{{ tournament.display_name }}{% endblock %} -{% block second_title %}Classement{% endblock %} +{% block first_title %}{{ tournament.broadcast_event_display_name }}{% endblock %} +{% block second_title %}Classement {{ tournament.broadcast_display_name }}{% endblock %} {% block content %} diff --git a/tournaments/templates/tournaments/broadcast/broadcasted_summons.html b/tournaments/templates/tournaments/broadcast/broadcasted_summons.html index a2a4007..08157a9 100644 --- a/tournaments/templates/tournaments/broadcast/broadcasted_summons.html +++ b/tournaments/templates/tournaments/broadcast/broadcasted_summons.html @@ -1,8 +1,8 @@ {% extends 'tournaments/broadcast/broadcast_base.html' %} {% block head_title %}Convocations{% endblock %} -{% block first_title %}{{ tournament.display_name }}{% endblock %} -{% block second_title %}Convocations{% endblock %} +{% block first_title %}{{ tournament.broadcast_event_display_name }}{% endblock %} +{% block second_title %}Convocations {{ tournament.broadcast_display_name }}{% endblock %} {% block content %} diff --git a/tournaments/utils/cryptography.py b/tournaments/utils/cryptography.py deleted file mode 100644 index 172c481..0000000 --- a/tournaments/utils/cryptography.py +++ /dev/null @@ -1,20 +0,0 @@ -from Crypto.Cipher import AES -import base64 - -def decrypt_aes_gcm(encrypted_base64, key_base64): - # Decode the base64 encoded data and key - encrypted_data = base64.b64decode(encrypted_base64) - key = base64.b64decode(key_base64) - - # Extract the nonce, tag, and ciphertext from the combined encrypted data - nonce = encrypted_data[:12] # AES GCM nonce is 12 bytes - tag = encrypted_data[-16:] # AES GCM tag is 16 bytes - ciphertext = encrypted_data[12:-16] # Ciphertext is everything in between - - # Create the cipher object and decrypt the data - cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) - decrypted_data = cipher.decrypt_and_verify(ciphertext, tag) - - # Convert decrypted bytes to string (assuming UTF-8 encoding) - decrypted_text = decrypted_data.decode('utf-8') - return decrypted_text