diff --git a/.env.example b/.env.example index b17fe55..0bd2321 100644 --- a/.env.example +++ b/.env.example @@ -15,4 +15,6 @@ DISCORD_CLIENT_SECRET=discord_client_secret STEAM_API_KEY=some_steam_api_key CSRF_TRUSTED_ORIGINS=http://localhost:8002 CORS_ALLOW_ALL_ORIGINS=True -SECURE_SSL_REDIRECT=False \ No newline at end of file +SECURE_SSL_REDIRECT=False +DJANGO_SUPERUSER_USERNAME=admin +DJANGO_SUPERUSER_PASSWORD=password \ No newline at end of file diff --git a/.gitignore b/.gitignore index 22515e5..79f97d6 100644 --- a/.gitignore +++ b/.gitignore @@ -265,4 +265,6 @@ pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/django,python *.sqlite3 -*.env.prod \ No newline at end of file +*.env.prod + +.idea/ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index a55e7a1..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/cs2-battle-bot-api.iml b/.idea/cs2-battle-bot-api.iml deleted file mode 100644 index 4693793..0000000 --- a/.idea/cs2-battle-bot-api.iml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml deleted file mode 100644 index d52521f..0000000 --- a/.idea/material_theme_project_new.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index b5b3f79..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index cc5bb4e..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/ruff.xml b/.idea/ruff.xml deleted file mode 100644 index dc7b5c1..0000000 --- a/.idea/ruff.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 05d2815..bf8b74a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,11 +13,13 @@ RUN poetry config virtualenvs.create false #RUN poetry config installer.no-binary cryptography # Copy the project files for dependency installation -COPY pyproject.toml ./poetry.lock /code/ +COPY ./pyproject.toml ./poetry.lock /code/ # Install project dependencies using Poetry RUN poetry install --no-interaction --no-ansi + + # Stage 2: Runtime environment FROM builder AS runtime ENV PYTHONUNBUFFERED 1 @@ -31,9 +33,11 @@ WORKDIR /app # Copy only the necessary files from the build stage COPY --from=builder /code /app +COPY --from=builder /code/pyproject.toml /app/ + + # Copy the source code COPY src /app/ -COPY scripts /app/scripts # Change ownership to the dedicated user diff --git a/poetry.lock b/poetry.lock index 4d45ef1..43191e0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -316,13 +316,13 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "django" -version = "5.0.5" +version = "5.0.6" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" files = [ - {file = "Django-5.0.5-py3-none-any.whl", hash = "sha256:8af4f166dc9a2bb822f9374cd78e34a10c286b402597fe2c7fb97c131656ba65"}, - {file = "Django-5.0.5.tar.gz", hash = "sha256:dc95c9cb2a37ba54599d9d1c8faf81609d36f3e74cd04395ce1300573e57baf9"}, + {file = "Django-5.0.6-py3-none-any.whl", hash = "sha256:8363ac062bb4ef7c3f12d078f6fa5d154031d129a15170a1066412af49d30905"}, + {file = "Django-5.0.6.tar.gz", hash = "sha256:ff1b61005004e476e0aeea47c7f79b85864c70124030e95146315396f1e7951f"}, ] [package.dependencies] @@ -1221,4 +1221,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "f633f3240916b78163cfcfb9b3e5b5241fddda3651312faf4654d031007fcad7" +content-hash = "9917045f6a776540ca286762b66f68e14ff89147a5935c05d8720a15eb270de7" diff --git a/pyproject.toml b/pyproject.toml index e8c47a1..66f66f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cs2-battle-bot-api" -version = "0.0.35" +version = "0.0.36" description = "" authors = ["Adrian Ciolek "] readme = "README.md" @@ -11,7 +11,7 @@ packages = [ [tool.poetry.dependencies] python = "^3.11" -django = "^5.0.2" +django = "^5.0.6" httpx = "^0.27.0" psycopg2-binary = "^2.9.9" django-prefix-id = "^1.0.0" diff --git a/scripts/install.sh b/scripts/install.sh index 468a551..f679adb 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,24 +1,2 @@ #!/bin/bash -echo "Building the project" -docker compose up -d --build -echo "Project is running" - -# Run migrations -echo "Running migrations" -docker compose exec app sh -c "cd src && python manage.py migrate" -echo "Migrations complete" - -# Loading map fixtures -echo "Loading map fixtures" -docker compose exec app sh -c "cd src && python manage.py loaddata maps" -echo "Map fixtures loaded" - -echo "Creating superuser" -# Create superuser -SUPER_USER=$(docker compose exec app sh -c "cd src && python manage.py shell -c \"from django.contrib.auth import get_user_model; User = get_user_model(); user = User.objects.create_superuser('admin', 'admin@myproject.com', 'password'); print(user);\"") -echo "Superuser $SUPER_USER created" - -echo "Creating API key" -# Create the API key -API_KEY=$(docker compose exec app sh -c "cd src && python manage.py shell -c \"from rest_framework_api_key.models import APIKey; api_key, key = APIKey.objects.create_key(name='cs2-battle-bot'); print(key);\"") -echo "Api key created: $API_KEY" \ No newline at end of file +docker compose exec app sh -c "cd src && ./install.sh" \ No newline at end of file diff --git a/src/cs2_battle_bot/settings.py b/src/cs2_battle_bot/settings.py index 7d934ba..19a9e0a 100644 --- a/src/cs2_battle_bot/settings.py +++ b/src/cs2_battle_bot/settings.py @@ -192,9 +192,12 @@ def get_spectacular_settings(): # Load the pyproject.toml file pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml" - if DEBUG is True: + pyproject_data = None + try: + pyproject_data = toml.load(pyproject_path) + except FileNotFoundError: pyproject_path = Path(__file__).resolve().parent.parent.parent / "pyproject.toml" - pyproject_data = toml.load(pyproject_path) + pyproject_data = toml.load(pyproject_path) # Get the name, version, and description name = pyproject_data.get("tool", {}).get("poetry", {}).get("name", "") @@ -204,6 +207,5 @@ def get_spectacular_settings(): # Assign them to the SPECTACULAR_SETTINGS dictionary return {"TITLE": name, "VERSION": version, "DESCRIPTION": description} - # Use the function SPECTACULAR_SETTINGS = get_spectacular_settings() diff --git a/src/guilds/admin.py b/src/guilds/admin.py index 37d2e37..9772bd0 100644 --- a/src/guilds/admin.py +++ b/src/guilds/admin.py @@ -2,5 +2,10 @@ from guilds.models import Guild +class GuildAdmin(admin.ModelAdmin): + list_filter = ('created_at', 'updated_at') + search_fields = ('name', 'created_at', 'updated_at') + readonly_fields = ('id', 'created_at', 'updated_at') + # Register your models here. -admin.site.register(Guild) \ No newline at end of file +admin.site.register(Guild, GuildAdmin) \ No newline at end of file diff --git a/src/guilds/migrations/0005_embed_embedfield_guild_embed_embed_fields.py b/src/guilds/migrations/0005_embed_embedfield_guild_embed_embed_fields.py new file mode 100644 index 0000000..7125649 --- /dev/null +++ b/src/guilds/migrations/0005_embed_embedfield_guild_embed_embed_fields.py @@ -0,0 +1,51 @@ +# Generated by Django 5.0.6 on 2024-05-10 10:20 + +import django.db.models.deletion +import prefix_id.field +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('guilds', '0004_remove_guild_members'), + ] + + operations = [ + migrations.CreateModel( + name='Embed', + fields=[ + ('id', prefix_id.field.PrefixIDField(editable=False, max_length=28, prefix='embed', primary_key=True, serialize=False, unique=True)), + ('title', models.CharField(blank=True, max_length=255, null=True)), + ('description', models.TextField(blank=True, null=True)), + ('color', models.CharField(blank=True, max_length=255, null=True)), + ('footer', models.CharField(blank=True, max_length=255, null=True)), + ('image', models.CharField(blank=True, max_length=255, null=True)), + ('thumbnail', models.CharField(blank=True, max_length=255, null=True)), + ('author', models.CharField(blank=True, max_length=255, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='EmbedField', + fields=[ + ('id', prefix_id.field.PrefixIDField(editable=False, max_length=34, prefix='embed_field', primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(blank=True, max_length=255, null=True)), + ('value', models.CharField(blank=True, max_length=255, null=True)), + ('inline', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.AddField( + model_name='guild', + name='embed', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='guilds.embed'), + ), + migrations.AddField( + model_name='embed', + name='fields', + field=models.ManyToManyField(blank=True, to='guilds.embedfield'), + ), + ] diff --git a/src/guilds/migrations/0006_embedfield_order.py b/src/guilds/migrations/0006_embedfield_order.py new file mode 100644 index 0000000..3f0c90d --- /dev/null +++ b/src/guilds/migrations/0006_embedfield_order.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-05-10 12:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('guilds', '0005_embed_embedfield_guild_embed_embed_fields'), + ] + + operations = [ + migrations.AddField( + model_name='embedfield', + name='order', + field=models.IntegerField(default=1), + ), + ] diff --git a/src/guilds/models.py b/src/guilds/models.py index e5a63a8..8b9da8a 100644 --- a/src/guilds/models.py +++ b/src/guilds/models.py @@ -1,14 +1,42 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver from prefix_id import PrefixIDField -from rest_framework_api_key.models import AbstractAPIKey from players.models import DiscordUser, Player UserModel = get_user_model() +class EmbedField(models.Model): + id = PrefixIDField(primary_key=True, prefix="embed_field") + order = models.IntegerField(default=1) + name = models.CharField(max_length=255, null=True, blank=True) + value = models.CharField(max_length=255, null=True, blank=True) + inline = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class Embed(models.Model): + id = PrefixIDField(primary_key=True, prefix="embed") + title = models.CharField(max_length=255, null=True, blank=True) + description = models.TextField(null=True, blank=True) + color = models.CharField(max_length=255, null=True, blank=True) + footer = models.CharField(max_length=255, null=True, blank=True) + image = models.CharField(max_length=255, null=True, blank=True) + thumbnail = models.CharField(max_length=255, null=True, blank=True) + author = models.CharField(max_length=255, null=True, blank=True) + fields = models.ManyToManyField(EmbedField, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.title + + # Create your models here. class GuildManager(models.Manager): @@ -35,8 +63,43 @@ class Guild(models.Model): lobby_channel = models.CharField(max_length=255, null=True, blank=True) team1_channel = models.CharField(max_length=255, null=True, blank=True) team2_channel = models.CharField(max_length=255, null=True, blank=True) + embed = models.ForeignKey(Embed, on_delete=models.CASCADE, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): return self.name + + +@receiver(post_save, sender=Guild) +def create_guild(sender, instance, created, **kwargs): + if created: + embed = Embed.objects.create( + title=f"Guild configuration {instance.name}", + author=instance.owner.player.discord_user.username + ) + fields = [ + EmbedField.objects.create( + name="Lobby Channel", + value=f"@<{instance.lobby_channel}" + ), + EmbedField.objects.create( + name="Team 1 Channel", + value=f"@<{instance.team1_channel}" + ), + EmbedField.objects.create( + name="Team 2 Channel", + value=f"@<{instance.team2_channel}" + ) + ] + embed.fields.set(fields) + instance.embed = embed + instance.save() + else: + instance.embed.title = f"Guild configuration {instance.name}" + instance.embed.author = instance.owner.player.discord_user.username + instance.embed.save() + instance.embed.fields.get(name="Lobby Channel").value = f"@<{instance.lobby_channel}" + instance.embed.fields.get(name="Team 1 Channel").value = f"@<{instance.team1_channel}" + instance.embed.fields.get(name="Team 2 Channel").value = f"@<{instance.team2_channel}" + instance.embed.save() \ No newline at end of file diff --git a/src/guilds/serializers.py b/src/guilds/serializers.py index d8aee19..daddd24 100644 --- a/src/guilds/serializers.py +++ b/src/guilds/serializers.py @@ -1,11 +1,10 @@ from rest_framework import serializers from accounts.serializers import UserSerializer -from guilds.models import Guild +from guilds.models import Guild, EmbedField, Embed class GuildSerializer(serializers.ModelSerializer): - owner = UserSerializer(read_only=True) class Meta: @@ -24,4 +23,26 @@ class UpdateGuildSerializer(serializers.Serializer): name = serializers.CharField(required=False) lobby_channel = serializers.CharField(required=False) team1_channel = serializers.CharField(required=False) - team2_channel = serializers.CharField(required=False) \ No newline at end of file + team2_channel = serializers.CharField(required=False) + + +class EmbedFieldSerializer(serializers.ModelSerializer): + class Meta: + model = EmbedField + fields = ["name", "value", "inline"] + + +class EmbedSerializer(serializers.ModelSerializer): + fields = EmbedFieldSerializer(many=True) + + class Meta: + model = Embed + fields = ["title", "description", "color", "footer", "image", "thumbnail", "author", "fields"] + + def to_representation(self, instance): + representation = super().to_representation(instance) + # Sort the fields by the 'order' attribute + representation['fields'] = sorted(instance.fields.all(), key=lambda x: x.order) + # Serialize the sorted fields + representation['fields'] = EmbedFieldSerializer(representation['fields'], many=True).data + return representation diff --git a/src/install.sh b/src/install.sh new file mode 100755 index 0000000..340b9bd --- /dev/null +++ b/src/install.sh @@ -0,0 +1,18 @@ +#!/bin/bash +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +# Run migrations +python manage.py migrate + +# Loading map fixtures +python manage.py loaddata maps + +# Create superuser +python manage.py createsuperuser --noinput --username "$DJANGO_SUPERUSER_USERNAME" +echo -e "${GREEN}Superuser ${RED}$DJANGO_SUPERUSER_USERNAME ${NC}created" + +# Create the API key +API_KEY=$(python manage.py shell -c "from rest_framework_api_key.models import APIKey; api_key, key = APIKey.objects.create_key(name='cs2-battle-bot'); print(key);") +echo -e "${GREEN}Api key created: ${RED}$API_KEY${NC}. Put this key to the .env file as API_KEY variable. You will not be able to see it again." \ No newline at end of file diff --git a/src/matches/admin.py b/src/matches/admin.py index cf36196..f54553b 100644 --- a/src/matches/admin.py +++ b/src/matches/admin.py @@ -1,8 +1,46 @@ from django.contrib import admin -from matches.models import Map, Match, MapBan +from matches.models import Map, Match, MapBan, MapPool, MatchConfig, MapPick + + +class MatchAdmin(admin.ModelAdmin): + list_filter = ('status', 'created_at', 'updated_at') + search_fields = ('team1__name', 'team2__name', 'status', 'winner_team__name', 'created_at', 'updated_at') + readonly_fields = ('id', 'created_at', 'updated_at') + + +class MatchConfigAdmin(admin.ModelAdmin): + list_filter = ('game_mode', 'type', 'clinch_series', 'max_players', 'created_at', 'updated_at') + search_fields = ('name', 'game_mode', 'type', 'clinch_series', 'max_players', 'created_at', 'updated_at') + readonly_fields = ('id', 'created_at', 'updated_at') + + +class MapAdmin(admin.ModelAdmin): + list_filter = ('guild', 'created_at', 'updated_at') + search_fields = ('name', 'guild__name', 'guild__id', 'created_at', 'updated_at') + readonly_fields = ('id', 'created_at', 'updated_at') + + +class MapPoolAdmin(admin.ModelAdmin): + list_filter = ('guild__name', 'created_at', 'updated_at') + search_fields = ('name', 'guild__name', 'guild__id', 'created_at', 'updated_at') + readonly_fields = ('id', 'created_at', 'updated_at') + + +class MapBanAdmin(admin.ModelAdmin): + list_filter = ('created_at', 'updated_at') + search_fields = ('created_at', 'updated_at') + readonly_fields = ('id', 'created_at', 'updated_at') + + +class MapPickAdmin(MapBanAdmin): + pass + # Register your models here. -admin.site.register(Match) -admin.site.register(Map) -admin.site.register(MapBan) +admin.site.register(Match, MatchAdmin) +admin.site.register(MatchConfig, MatchConfigAdmin) +admin.site.register(Map, MapAdmin) +admin.site.register(MapPool, MapPoolAdmin) +admin.site.register(MapBan, MapBanAdmin) +admin.site.register(MapPick, MapPickAdmin) diff --git a/src/matches/migrations/0020_remove_match_clinch_series_remove_match_map_sides_and_more.py b/src/matches/migrations/0020_remove_match_clinch_series_remove_match_map_sides_and_more.py new file mode 100644 index 0000000..57486b1 --- /dev/null +++ b/src/matches/migrations/0020_remove_match_clinch_series_remove_match_map_sides_and_more.py @@ -0,0 +1,77 @@ +# Generated by Django 5.0.6 on 2024-05-09 20:18 + +import django.db.models.deletion +import prefix_id.field +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('guilds', '0004_remove_guild_members'), + ('matches', '0019_match_last_map_ban_match_last_map_pick'), + ] + + operations = [ + migrations.RemoveField( + model_name='match', + name='clinch_series', + ), + migrations.RemoveField( + model_name='match', + name='map_sides', + ), + migrations.RemoveField( + model_name='match', + name='maps', + ), + migrations.RemoveField( + model_name='match', + name='num_maps', + ), + migrations.RemoveField( + model_name='match', + name='players_per_team', + ), + migrations.RemoveField( + model_name='match', + name='type', + ), + migrations.CreateModel( + name='MapPool', + fields=[ + ('id', prefix_id.field.PrefixIDField(editable=False, max_length=31, prefix='map_pool', primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('guild', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='map_pools', to='guilds.guild')), + ('maps', models.ManyToManyField(related_name='map_pools', to='matches.map')), + ], + ), + migrations.AddField( + model_name='match', + name='map_pool', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='matches.mappool'), + ), + migrations.CreateModel( + name='MatchConfig', + fields=[ + ('id', prefix_id.field.PrefixIDField(editable=False, max_length=35, prefix='match_config', primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, unique=True)), + ('game_mode', models.CharField(choices=[('COMPETITIVE', 'Competitive'), ('WINGMAN', 'Wingman'), ('AIM', 'Aim')], default='COMPETITIVE', max_length=255)), + ('type', models.CharField(choices=[('BO1', 'Bo1'), ('BO3', 'Bo3'), ('BO5', 'Bo5')], default='BO1', max_length=255)), + ('map_sides', models.JSONField(null=True)), + ('clinch_series', models.BooleanField(default=False)), + ('max_players', models.PositiveIntegerField(default=10)), + ('cvars', models.JSONField(null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('guild', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='match_configs', to='guilds.guild')), + ], + ), + migrations.AddField( + model_name='match', + name='config', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='matches.matchconfig'), + ), + ] diff --git a/src/matches/migrations/0021_map_guild_alter_map_name_alter_map_tag.py b/src/matches/migrations/0021_map_guild_alter_map_name_alter_map_tag.py new file mode 100644 index 0000000..8f92dce --- /dev/null +++ b/src/matches/migrations/0021_map_guild_alter_map_name_alter_map_tag.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.6 on 2024-05-09 20:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('guilds', '0004_remove_guild_members'), + ('matches', '0020_remove_match_clinch_series_remove_match_map_sides_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='map', + name='guild', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='maps', to='guilds.guild'), + ), + migrations.AlterField( + model_name='map', + name='name', + field=models.CharField(max_length=255, unique=True), + ), + migrations.AlterField( + model_name='map', + name='tag', + field=models.CharField(max_length=255, unique=True), + ), + ] diff --git a/src/matches/migrations/0022_alter_map_guild_alter_mappool_guild_and_more.py b/src/matches/migrations/0022_alter_map_guild_alter_mappool_guild_and_more.py new file mode 100644 index 0000000..404f969 --- /dev/null +++ b/src/matches/migrations/0022_alter_map_guild_alter_mappool_guild_and_more.py @@ -0,0 +1,102 @@ +# Generated by Django 5.0.6 on 2024-05-09 20:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('guilds', '0004_remove_guild_members'), + ('matches', '0021_map_guild_alter_map_name_alter_map_tag'), + ('players', '0005_alter_player_steam_user'), + ('servers', '0006_remove_server_max_players'), + ] + + operations = [ + migrations.AlterField( + model_name='map', + name='guild', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='maps', to='guilds.guild'), + ), + migrations.AlterField( + model_name='mappool', + name='guild', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='map_pools', to='guilds.guild'), + ), + migrations.AlterField( + model_name='match', + name='author', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='players.discorduser'), + ), + migrations.AlterField( + model_name='match', + name='config', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='matches.matchconfig'), + ), + migrations.AlterField( + model_name='match', + name='cvars', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='match', + name='guild', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='guilds.guild'), + ), + migrations.AlterField( + model_name='match', + name='last_map_ban', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches_last_map_ban', to='matches.mapban'), + ), + migrations.AlterField( + model_name='match', + name='last_map_pick', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches_last_map_pick', to='matches.mappick'), + ), + migrations.AlterField( + model_name='match', + name='map_pool', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='matches.mappool'), + ), + migrations.AlterField( + model_name='match', + name='maplist', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='match', + name='message_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='match', + name='server', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='servers.server'), + ), + migrations.AlterField( + model_name='match', + name='status', + field=models.CharField(choices=[('CREATED', 'Created'), ('STARTED', 'Started'), ('LIVE', 'Live'), ('FINISHED', 'Finished'), ('CANCELLED', 'Cancelled')], default='CREATED', max_length=255), + ), + migrations.AlterField( + model_name='match', + name='team1', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches_team1', to='players.team'), + ), + migrations.AlterField( + model_name='match', + name='team2', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches_team2', to='players.team'), + ), + migrations.AlterField( + model_name='match', + name='winner_team', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches_winner', to='players.team'), + ), + migrations.AlterField( + model_name='matchconfig', + name='guild', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='match_configs', to='guilds.guild'), + ), + ] diff --git a/src/matches/migrations/0023_alter_mappick_map_alter_match_author_and_more.py b/src/matches/migrations/0023_alter_mappick_map_alter_match_author_and_more.py new file mode 100644 index 0000000..8a00da5 --- /dev/null +++ b/src/matches/migrations/0023_alter_mappick_map_alter_match_author_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 5.0.6 on 2024-05-09 21:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('matches', '0022_alter_map_guild_alter_mappool_guild_and_more'), + ('players', '0005_alter_player_steam_user'), + ] + + operations = [ + migrations.AlterField( + model_name='mappick', + name='map', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='map_picks', to='matches.map'), + ), + migrations.AlterField( + model_name='match', + name='author', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='players.discorduser'), + ), + migrations.AlterField( + model_name='match', + name='map_bans', + field=models.ManyToManyField(blank=True, related_name='matches_map_bans', to='matches.mapban'), + ), + migrations.AlterField( + model_name='match', + name='map_picks', + field=models.ManyToManyField(blank=True, related_name='matches_map_picks', to='matches.mappick'), + ), + migrations.AlterField( + model_name='matchconfig', + name='cvars', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='matchconfig', + name='map_sides', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/src/matches/migrations/0024_remove_match_map_pool_matchconfig_map_pool.py b/src/matches/migrations/0024_remove_match_map_pool_matchconfig_map_pool.py new file mode 100644 index 0000000..5eb9038 --- /dev/null +++ b/src/matches/migrations/0024_remove_match_map_pool_matchconfig_map_pool.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2024-05-09 21:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('matches', '0023_alter_mappick_map_alter_match_author_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='match', + name='map_pool', + ), + migrations.AddField( + model_name='matchconfig', + name='map_pool', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='match_configs', to='matches.mappool'), + ), + ] diff --git a/src/matches/migrations/0025_matchconfig_shuffle_teams_alter_match_status.py b/src/matches/migrations/0025_matchconfig_shuffle_teams_alter_match_status.py new file mode 100644 index 0000000..9d37af8 --- /dev/null +++ b/src/matches/migrations/0025_matchconfig_shuffle_teams_alter_match_status.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2024-05-09 21:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('matches', '0024_remove_match_map_pool_matchconfig_map_pool'), + ] + + operations = [ + migrations.AddField( + model_name='matchconfig', + name='shuffle_teams', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='match', + name='status', + field=models.CharField(choices=[('CREATED', 'Created'), ('STARTED', 'Started'), ('LOADED', 'Loaded'), ('LIVE', 'Live'), ('FINISHED', 'Finished'), ('CANCELLED', 'Cancelled')], default='CREATED', max_length=255), + ), + ] diff --git a/src/matches/migrations/0026_match_embed.py b/src/matches/migrations/0026_match_embed.py new file mode 100644 index 0000000..c616efc --- /dev/null +++ b/src/matches/migrations/0026_match_embed.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.6 on 2024-05-10 10:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('guilds', '0005_embed_embedfield_guild_embed_embed_fields'), + ('matches', '0025_matchconfig_shuffle_teams_alter_match_status'), + ] + + operations = [ + migrations.AddField( + model_name='match', + name='embed', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='guilds.embed'), + ), + ] diff --git a/src/matches/migrations/0027_alter_match_status.py b/src/matches/migrations/0027_alter_match_status.py new file mode 100644 index 0000000..dd57cf9 --- /dev/null +++ b/src/matches/migrations/0027_alter_match_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-05-10 14:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('matches', '0026_match_embed'), + ] + + operations = [ + migrations.AlterField( + model_name='match', + name='status', + field=models.CharField(choices=[('CREATED', 'Created'), ('CAPTAINS_SELECT', 'Captains Select'), ('MAP_VETO', 'Map Veto'), ('READY_TO_LOAD', 'Ready To Load'), ('LOADED', 'Loaded'), ('LIVE', 'Live'), ('FINISHED', 'Finished'), ('CANCELLED', 'Cancelled')], default='CREATED', max_length=255), + ), + ] diff --git a/src/matches/migrations/0028_alter_match_last_map_ban_alter_match_last_map_pick.py b/src/matches/migrations/0028_alter_match_last_map_ban_alter_match_last_map_pick.py new file mode 100644 index 0000000..422e7da --- /dev/null +++ b/src/matches/migrations/0028_alter_match_last_map_ban_alter_match_last_map_pick.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.6 on 2024-05-10 16:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('matches', '0027_alter_match_status'), + ] + + operations = [ + migrations.AlterField( + model_name='match', + name='last_map_ban', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='matches_last_map_ban', to='matches.mapban'), + ), + migrations.AlterField( + model_name='match', + name='last_map_pick', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='matches_last_map_pick', to='matches.mappick'), + ), + ] diff --git a/src/matches/models.py b/src/matches/models.py index 657eaba..4b89115 100644 --- a/src/matches/models.py +++ b/src/matches/models.py @@ -3,18 +3,25 @@ from django.contrib.auth import get_user_model from django.db import models from django.db.models import Q +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver from prefix_id import PrefixIDField -from players.models import Team +from guilds.models import Embed, EmbedField +from players.models import Team, Player UserModel = get_user_model() class MatchStatus(models.TextChoices): CREATED = "CREATED" - STARTED = "STARTED" + CAPTAINS_SELECT = "CAPTAINS_SELECT" + MAP_VETO = "MAP_VETO" + READY_TO_LOAD = "READY_TO_LOAD" + LOADED = "LOADED" LIVE = "LIVE" FINISHED = "FINISHED" + CANCELLED = "CANCELLED" class MatchType(models.TextChoices): @@ -23,15 +30,34 @@ class MatchType(models.TextChoices): BO5 = "BO5" +class GameMode(models.TextChoices): + COMPETITIVE = "COMPETITIVE" + WINGMAN = "WINGMAN" + AIM = "AIM" + + class Map(models.Model): id = PrefixIDField(primary_key=True, prefix="map") - name = models.CharField(max_length=255) - tag = models.CharField(max_length=255) + name = models.CharField(max_length=255, unique=True) + tag = models.CharField(max_length=255, unique=True) + guild = models.ForeignKey("guilds.Guild", on_delete=models.CASCADE, related_name="maps", null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"<{self.name} - {self.tag}>" + + +class MapPool(models.Model): + id = PrefixIDField(primary_key=True, prefix="map_pool") + name = models.CharField(max_length=255, unique=True) + maps = models.ManyToManyField(Map, related_name="map_pools") + guild = models.ForeignKey("guilds.Guild", on_delete=models.CASCADE, related_name="map_pools", null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): - return f"<{self.name} - {self.tag} - {self.id}>" + return self.name if not self.guild else f"<{self.name} - {self.guild.name}>" class MapBan(models.Model): @@ -48,7 +74,7 @@ class MapPick(models.Model): team = models.ForeignKey( Team, on_delete=models.CASCADE, related_name="map_selected" ) - map = models.ForeignKey(Map, on_delete=models.CASCADE, related_name="map_selected") + map = models.ForeignKey(Map, on_delete=models.CASCADE, related_name="map_picks") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -56,40 +82,31 @@ def __str__(self): return f"<{self.team.name} - {self.map.name}>" +class MatchConfig(models.Model): + id = PrefixIDField(primary_key=True, prefix="match_config") + name = models.CharField(max_length=255, unique=True) + game_mode = models.CharField( + max_length=255, choices=GameMode.choices, default=GameMode.COMPETITIVE + ) + type = models.CharField( + max_length=255, choices=MatchType.choices, default=MatchType.BO1 + ) + map_pool = models.ForeignKey(MapPool, on_delete=models.CASCADE, related_name="match_configs", null=True, blank=True) + map_sides = models.JSONField(null=True, blank=True) + clinch_series = models.BooleanField(default=False) + max_players = models.PositiveIntegerField(default=10) + cvars = models.JSONField(null=True, blank=True) + guild = models.ForeignKey("guilds.Guild", on_delete=models.CASCADE, related_name="match_configs", null=True, + blank=True) + shuffle_teams = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + class MatchManager(models.Manager): - def create_match(self, **kwargs): - maps = Map.objects.all() - maplist = kwargs.pop("maplist", None) - if not maplist: - maplist = [map.tag for map in maps] - print(maplist) - map_sides = kwargs.pop("map_sides", ["knife", "knife", "knife"]) - match_type = kwargs.pop("type", MatchType.BO1) - team1 = kwargs.pop("team1", None) - team2 = kwargs.pop("team2", None) - author = kwargs.pop("author", None) - server = kwargs.pop("server", None) - num_maps = kwargs.pop("num_maps", 1 if match_type == MatchType.BO1 else 3) - cvars = kwargs.pop("cvars", None) - players_list = team1.players.all() | team2.players.all() - player_per_team = len(players_list) / 2 - players_per_team_rounded = math.ceil(player_per_team) - match = self.create( - **kwargs, - type=match_type, - team1=team1, - team2=team2, - maplist=maplist, - map_sides=map_sides, - num_maps=num_maps, - players_per_team=players_per_team_rounded, - server=server, - author=author, - cvars=cvars, - ) - match.maps.set(maps) - match.save() - return match def check_server_is_available_for_match(self, server): return not self.filter( @@ -97,47 +114,46 @@ def check_server_is_available_for_match(self, server): (Q(status=MatchStatus.LIVE) | Q(status=MatchStatus.STARTED)) ).exists() + # Create your models here. class Match(models.Model): objects = MatchManager() - status = models.CharField( max_length=255, choices=MatchStatus.choices, default=MatchStatus.CREATED ) - type = models.CharField( - max_length=255, choices=MatchType.choices, default=MatchType.BO1 - ) - maps = models.ManyToManyField(Map, related_name="matches_maps") + config = models.ForeignKey(MatchConfig, on_delete=models.CASCADE, related_name="matches", null=True, blank=True) team1 = models.ForeignKey( - Team, on_delete=models.CASCADE, related_name="matches_team1", null=True + "players.Team", on_delete=models.CASCADE, related_name="matches_team1", null=True, blank=True ) team2 = models.ForeignKey( - Team, on_delete=models.CASCADE, related_name="matches_team2", null=True + "players.Team", on_delete=models.CASCADE, related_name="matches_team2", null=True, blank=True ) winner_team = models.ForeignKey( - Team, on_delete=models.CASCADE, related_name="matches_winner", null=True + "players.Team", on_delete=models.CASCADE, related_name="matches_winner", null=True, blank=True ) - map_bans = models.ManyToManyField(MapBan, related_name="matches_map_bans") + map_bans = models.ManyToManyField(MapBan, related_name="matches_map_bans", blank=True) map_picks = models.ManyToManyField( - MapPick, related_name="matches_map_picks" + MapPick, related_name="matches_map_picks", blank=True ) - last_map_ban = models.ForeignKey(MapBan, on_delete=models.CASCADE, related_name="matches_last_map_ban", null=True) - last_map_pick = models.ForeignKey(MapPick, on_delete=models.CASCADE, related_name="matches_last_map_pick", null=True) - num_maps = models.PositiveIntegerField(default=1) - maplist = models.JSONField(null=True) - map_sides = models.JSONField(null=True) - clinch_series = models.BooleanField(default=False) - cvars = models.JSONField(null=True) - players_per_team = models.PositiveIntegerField(default=5) - message_id = models.CharField(max_length=255, null=True) + last_map_ban = models.ForeignKey(MapBan, on_delete=models.SET_NULL, related_name="matches_last_map_ban", null=True, + blank=True) + last_map_pick = models.ForeignKey(MapPick, on_delete=models.SET_NULL, related_name="matches_last_map_pick", + null=True, blank=True) + maplist = models.JSONField(null=True, blank=True) + cvars = models.JSONField(null=True, blank=True) + message_id = models.CharField(max_length=255, null=True, blank=True) author = models.ForeignKey("players.DiscordUser", on_delete=models.CASCADE, related_name="matches", null=True) server = models.ForeignKey( - "servers.Server", on_delete=models.CASCADE, related_name="matches", null=True + "servers.Server", on_delete=models.CASCADE, related_name="matches", null=True, blank=True ) - guild = models.ForeignKey("guilds.Guild", on_delete=models.CASCADE, related_name="matches", null=True) + guild = models.ForeignKey("guilds.Guild", on_delete=models.CASCADE, related_name="matches", null=True, blank=True) + embed = models.ForeignKey("guilds.Embed", on_delete=models.CASCADE, related_name="matches", null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + def __str__(self): + return f"<{self.status} - {self.config.name} - {self.pk}>" + @property def api_key_header(self): return "Authorization" @@ -146,8 +162,6 @@ def api_key_header(self): def load_match_command_name(self): return "matchzy_loadmatch_url" - def __str__(self): - return f"<{self.team1.name} vs {self.team2.name}- {self.status} - {self.type} - {self.pk}>" def get_team1_players_dict(self): return { @@ -161,20 +175,29 @@ def get_team2_players_dict(self): "players": self.team2.get_players_dict(), } - def get_config(self): - config = { + def get_matchzy_config(self): + num_maps = 1 if self.config.type == MatchType.BO1 else 3 + players_count = self.team1.players.count() + self.team2.players.count() + players_per_team = players_count / 2 + players_per_team_rounded = math.ceil(players_per_team) + matchzy_config = { "matchid": self.pk, "team1": self.get_team1_players_dict(), "team2": self.get_team2_players_dict(), - "num_maps": self.num_maps, + "num_maps": num_maps, "maplist": self.maplist, - "map_sides": self.map_sides, - "clinch_series": self.clinch_series, - "players_per_team": self.players_per_team, + "map_sides": self.config.map_sides, + "clinch_series": self.config.clinch_series, + "players_per_team": players_per_team_rounded, } if self.cvars: - config["cvars"] = self.cvars - return config + config_cvars = self.config.cvars + if config_cvars: + self.cvars.update(config_cvars) + matchzy_config["cvars"] = self.cvars + if self.config.game_mode == GameMode.WINGMAN: + matchzy_config["wingman"] = True + return matchzy_config def get_connect_command(self): return "" if not self.server else self.server.get_connect_string() @@ -191,28 +214,279 @@ def create_webhook_cvars(self, webhook_url: str): }) self.save() - def get_maps_tags(self): - return [map.tag for map in self.maps.all()] - def ban_map(self, team, map): map_ban = MapBan.objects.create(team=team, map=map) self.map_bans.add(map_ban) - self.maps.remove(map) - self.maplist.remove(map.tag) self.last_map_ban = map_ban + + if self.config.type == MatchType.BO1: + # 6 bans + map_bans_count = self.map_bans.count() + # 7 maps + map_pool_count = self.config.map_pool.maps.count() - 1 + if map_bans_count == map_pool_count: + # Select the last map + map_to_select = self.config.map_pool.maps.exclude(tag__in=self.map_bans.values_list("map__tag", flat=True)).exclude( + tag__in=self.map_picks.values_list("map__tag", flat=True)).first() + self.maplist = [map_to_select.tag] + self.status = MatchStatus.READY_TO_LOAD + elif self.config.type == MatchType.BO3: + # 4 bans + map_bans_count = self.map_bans.count() + # 7 maps + map_pool_count = self.config.map_pool.maps.count() - 3 + if map_bans_count == map_pool_count: + map_to_select = self.config.map_pool.maps.exclude( + tag__in=self.map_bans.values_list("map__tag", flat=True)).exclude( + tag__in=self.map_picks.values_list("map__tag", flat=True)).first() + self.maplist.append(map_to_select.tag) + self.status = MatchStatus.READY_TO_LOAD + self.save() return self def pick_map(self, team, map): map_pick = MapPick.objects.create(team=team, map=map) self.map_picks.add(map_pick) - map_index = self.maplist.index(map.tag) - self.maplist.pop(map_index) - map_picks_count = self.map_picks.count() - if map_picks_count == 1: - self.maplist.insert(0, map.tag) - else: - self.maplist.insert(1, map.tag) + # add map to maplist without removing it + self.maplist.append(map.tag) self.last_map_pick = map_pick self.save() - return self \ No newline at end of file + return self + + def shuffle_players(self): + players_list = list(self.team1.players.all()) + list(self.team2.players.all()) + players_count = len(players_list) + middle_index = players_count // 2 + team1_players = players_list[:middle_index] + team2_players = players_list[middle_index:] + self.team1.players.set(team1_players) + self.team1.leader = team1_players[0] + self.team2.players.set(team2_players) + self.team2.leader = team2_players[0] + self.save() + + def change_teams_name(self): + self.team1.name = f"team_{self.team1.leader.steam_user.username}" + self.team1.save() + self.team2.name = f"team_{self.team2.leader.steam_user.username}" + self.team2.save() + + def add_player_to_match(self, player, team: str = None): + if team: + if team == "team1": + if self.team2.players.filter(id=player.id).exists(): + self.team2.players.remove(player) + self.team1.players.add(player) + elif team == "team2": + if self.team1.players.filter(id=player.id).exists(): + self.team1.players.remove(player) + self.team2.players.add(player) + else: + if self.team1.players.count() < self.team2.players.count(): + self.team1.players.add(player) + self.embed.fields.get(name="Team 1").value = f""" + ``` + Name: {self.team1.name} + Captain: {self.team1.leader.player.discord_user.username if self.team1.leader else "No captain"} + ``` + ``` + {[player.discord_user.username for player in self.team1.players.all() if self.team1.players.count() > 0]} + ``` + """ + elif self.team2.players.count() < self.team1.players.count(): + self.team2.players.add(player) + self.embed.fields.get(name="Team 2").value = f""" + ``` + Name: {self.team2.name} + Captain: {self.team2.leader.player.discord_user.username if self.team2.leader else "No captain"} + ``` + ``` + {[player.discord_user.username for player in self.team2.players.all() if self.team2.players.count() > 0]} + ``` + """ + else: + self.team1.players.add(player) + self.embed.save() + self.save() + + return self + + def remove_player_from_match(self, player): + if player in self.team1.players.all(): + self.team1.players.remove(player) + elif player in self.team2.players.all(): + self.team2.players.remove(player) + self.save() + return self + + def start_match(self): + if self.config.shuffle_teams: + self.shuffle_players() + self.change_teams_name() + self.status = MatchStatus.MAP_VETO + else: + self.status = MatchStatus.CAPTAINS_SELECT + + self.save() + return self + + def set_team_captain(self, player, team): + if team == "team1": + print("Setting team1 captain") + self.team1.leader = player + self.team1.save() + elif team == "team2": + print("Setting team2 captain") + self.team2.leader = player + self.team2.save() + if self.team1.leader and self.team2.leader: + self.status = MatchStatus.MAP_VETO + self.change_teams_name() + self.save() + return self + + def get_team_by_player(self, player): + if player in self.team1.players.all(): + return self.team1 + elif player in self.team2.players.all(): + return self.team2 + return None + + def get_maps_left(self): + maps_left = self.config.map_pool.maps.exclude(tag__in=self.map_bans.values_list("map__tag", flat=True)).exclude( + tag__in=self.map_picks.values_list("map__tag", flat=True)) + if self.config.type == MatchType.BO1 and len(maps_left) == 1: + return [] + return [map.tag for map in maps_left] + + def get_next_ban_team(self): + return self.team1 if self.last_map_ban.team == self.team2 else self.team2 + +@receiver(post_save, sender=Match) +def match_post_save(sender, instance, created, **kwargs): + if created: + team1_player = Player.objects.get(discord_user=instance.author) + team1 = Team.objects.create(name="Team 1") + team1.players.add(team1_player) + team1.save() + team2 = Team.objects.create(name="Team 2") + + instance.team1 = team1 + instance.team2 = team2 + + print(f"Created {team1}") + print(f"Created {team2}") + + # create embed + embed = Embed.objects.create( + title=f"Match {instance.pk} - {instance.config.name}", + description=f""" + ``` + Config: {instance.config.name} + Type: {instance.config.type} + Status: {instance.status} + Game mode: {instance.config.game_mode} + Map Pool: {instance.config.map_pool.name if instance.config.map_pool else "No map pool"} + Max Players: {instance.config.max_players} + Map Sides: {instance.config.map_sides} + Clinch Series: {instance.config.clinch_series} + Custom cvars: {instance.config.cvars if instance.config.cvars else "No custom cvars"} + ``` + """, + author=instance.author.username, + footer=f"{instance.pk} {instance.created_at}" + ) + team1_players = [player.discord_user.username for player in team1.players.all()] + team2_players = [player.discord_user.username for player in team2.players.all()] + team1_players_str = "\n".join(team1_players) + team2_players_str = "\n".join(team2_players) + + fields = [ + EmbedField.objects.create( + order=1, + name="Team 1", + value=f""" + ``` + Name: {instance.team1.name} + Captain: {instance.team1.leader.discord_user.username if instance.team1.leader else "No captain"} + ``` + ``` + {team1_players_str} + ``` + """, + inline=True + ), + EmbedField.objects.create( + order=2, + name="Team 2", + value=f""" + ``` + Name: {instance.team2.name} + Captain: {instance.team2.leader.discord_user.username if instance.team2.leader else "No captain"} + ``` + ``` + {team2_players_str} + ``` + """, + inline=True + ) + ] + embed.fields.set(fields) + instance.embed = embed + instance.save() + else: + print("Match updated") + print(f"Team 1 {instance.team1.players.count()}") + print(f"Team 2 {instance.team2.players.count()}") + instance.embed.title = f"Match {instance.pk}" + instance.embed.description = f""" + ``` + Config: {instance.config.name} + Type: {instance.config.type} + Status: {instance.status} + Game mode: {instance.config.game_mode} + Map Pool: {instance.config.map_pool.name if instance.config.map_pool else "No map pool"} + Max Players: {instance.config.max_players} + Map Sides: {instance.config.map_sides} + Clinch Series: {instance.config.clinch_series} + Custom cvars: {instance.config.cvars if instance.config.cvars else "No custom cvars"} + ``` + """ + + team1_players = [player.discord_user.username for player in instance.team1.players.all()] + team2_players = [player.discord_user.username for player in instance.team2.players.all()] + team1_players_str = "\n".join(team1_players) + team2_players_str = "\n".join(team2_players) + + team1_field = instance.embed.fields.get(name="Team 1") + team1_field.value = f""" + ``` + Name: {instance.team1.name} + Captain: {instance.team1.leader.discord_user.username if instance.team1.leader else "No captain"} + ``` + ``` + {team1_players_str} + ``` + """ + team1_field.save() + team2_field = instance.embed.fields.get(name="Team 2") + team2_field.value = f""" + ``` + Name: {instance.team2.name} + Captain: {instance.team2.leader.discord_user.username if instance.team2.leader else "No captain"} + ``` + ``` + {team2_players_str} + ``` + """ + team2_field.save() + if instance.status == MatchStatus.READY_TO_LOAD: + if instance.server: + server_detail_field = EmbedField.objects.create( + order=instance.embed.fields.last().order + 1, + name="Server details", + value=f"```{instance.get_connect_command()}```" + ) + instance.embed.fields.add(server_detail_field) + instance.embed.save() diff --git a/src/matches/serializers.py b/src/matches/serializers.py index 4fa5576..4f2734f 100644 --- a/src/matches/serializers.py +++ b/src/matches/serializers.py @@ -1,11 +1,16 @@ from enum import Enum -import re + from rest_framework import serializers from rest_framework.reverse import reverse_lazy -from guilds.serializers import GuildSerializer -from matches.models import Map, MapBan, MapPick, Match, MatchType, MatchStatus +from guilds.models import Guild +from guilds.serializers import GuildSerializer, EmbedSerializer +from matches.models import Map, MapBan, MapPick, Match, MatchType, MatchStatus, MatchConfig, MapPool +from matches.validators import ValidDiscordUser, DiscordUserCanJoinMatch, DiscordUserCanLeaveMatch, TeamCanBeJoined, \ + DiscordUserIsInMatch, ValidMap, MapCanBeBanned +from players.models import DiscordUser from players.serializers import TeamSerializer, DiscordUserSerializer +from servers.models import Server from servers.serializers import ServerSerializer @@ -26,6 +31,14 @@ class Meta: fields = "__all__" +class MapPoolSerializer(serializers.ModelSerializer): + maps = MapSerializer(many=True, read_only=True) + + class Meta: + model = MapPool + fields = "__all__" + + class MapBanSerializer(serializers.ModelSerializer): team = TeamSerializer(read_only=True) map = MapSerializer(read_only=True) @@ -44,7 +57,15 @@ class Meta: fields = "__all__" -class MatchConfigSerializer(serializers.Serializer): +class MatchConfigSerializer(serializers.ModelSerializer): + map_pool = MapPoolSerializer(read_only=True) + + class Meta: + model = MatchConfig + fields = "__all__" + + +class MatchzyConfigSerializer(serializers.Serializer): matchid = serializers.CharField() team1 = serializers.DictField() team2 = serializers.DictField() @@ -56,11 +77,13 @@ class MatchConfigSerializer(serializers.Serializer): ) ) clinch_series = serializers.BooleanField() + wingman = serializers.BooleanField(required=False) players_per_team = serializers.IntegerField() cvars = serializers.DictField(required=False) class MatchSerializer(serializers.ModelSerializer): + config = MatchConfigSerializer(read_only=True) team1 = TeamSerializer(read_only=True) team2 = TeamSerializer(read_only=True) maps = MapSerializer(many=True, read_only=True) @@ -72,27 +95,27 @@ class MatchSerializer(serializers.ModelSerializer): author = DiscordUserSerializer(read_only=True) server = ServerSerializer(read_only=True, required=False, allow_null=True) guild = GuildSerializer(read_only=True) - config_url = serializers.SerializerMethodField(method_name="get_config_url") - config = serializers.SerializerMethodField(method_name="get_config") - + matchzy_config_url = serializers.SerializerMethodField(method_name="get_matchzy_config_url") + matchzy_config = serializers.SerializerMethodField(method_name="get_matchzy_config") webhook_url = serializers.SerializerMethodField(method_name="get_webhook_url") connect_command = serializers.CharField( read_only=True, source="get_connect_command" ) load_match_command = serializers.SerializerMethodField(method_name="get_load_match_command") + embed = EmbedSerializer(read_only=True, allow_null=True) - def get_config_url(self, obj) -> str: + def get_matchzy_config_url(self, obj) -> str: return reverse_lazy("match-config", args=[obj.id], request=self.context["request"]) def get_webhook_url(self, obj) -> str: return reverse_lazy("match-webhook", args=[obj.id], request=self.context["request"]) def get_load_match_command(self, obj) -> str: - config_url = self.get_config_url(obj) + config_url = self.get_matchzy_config_url(obj) return f'{obj.load_match_command_name} "{config_url}" "{obj.api_key_header}" "Bearer {obj.get_author_token()}"' - def get_config(self, obj) -> MatchConfigSerializer: - return MatchConfigSerializer(obj.get_config()).data + def get_matchzy_config(self, obj) -> MatchConfigSerializer: + return MatchzyConfigSerializer(obj.get_matchzy_config()).data class Meta: model = Match @@ -118,27 +141,31 @@ class MatchUpdateSerializer(serializers.Serializer): guild_id = serializers.CharField(required=False) - class CreateMatchSerializer(serializers.Serializer): - discord_users_ids = serializers.ListField(child=serializers.CharField()) + config_name = serializers.CharField(required=True) author_id = serializers.CharField(required=True) server_id = serializers.CharField(required=False) guild_id = serializers.CharField(required=True) - match_type = serializers.ChoiceField( - choices=MatchType.choices, default=MatchType.BO1 - ) - clinch_series = serializers.BooleanField(required=False, default=False) - map_sides = serializers.ListField( - child=serializers.ChoiceField( - choices=["team1_ct", "team2_ct", "team1_t", "team2_t", "knife"] - ), - required=False, - default=["knife", "knife", "knife"], - ) - cvars = serializers.DictField( - child=serializers.CharField(required=False), required=False - ) - maplist = serializers.ListField(child=serializers.CharField(), required=False) + + def validate_config_name(self, value): + if not MatchConfig.objects.filter(name=value).exists(): + raise serializers.ValidationError(f"MatchConfig with name {value} does not exist") + return value + + def validate_author_id(self, value): + if not DiscordUser.objects.filter(user_id=value).exists(): + raise serializers.ValidationError(f"Author[DiscordUser] with id {value} does not exist") + return value + + def validate_server_id(self, value): + if value and not Server.objects.filter(id=value).exists(): + raise serializers.ValidationError(f"Server with id {value} does not exist") + return value + + def validate_guild_id(self, value): + if not Guild.objects.filter(guild_id=value).exists(): + raise serializers.ValidationError(f"Guild with id {value} does not exist") + return value class MatchTeamWrapperSerializer(serializers.Serializer): @@ -195,11 +222,12 @@ class MatchEventMapResultSerializer(MatchEventSerializer): class InteractionUserSerializer(serializers.Serializer): - interaction_user_id = serializers.CharField(required=True) + interaction_user_id = serializers.CharField(required=True, validators=[ValidDiscordUser()]) -class MatchBanMapSerializer(InteractionUserSerializer): - map_tag = serializers.CharField(required=True) +class MatchBanMapSerializer(serializers.Serializer): + interaction_user_id = serializers.CharField(required=True, validators=[ValidDiscordUser(), DiscordUserIsInMatch()]) + map_tag = serializers.CharField(required=True, validators=[ValidMap(), MapCanBeBanned()]) class MatchPickMapSerializer(MatchBanMapSerializer): @@ -207,11 +235,16 @@ class MatchPickMapSerializer(MatchBanMapSerializer): class MatchBanMapResultSerializer(serializers.Serializer): + match = serializers.SerializerMethodField(method_name="get_match") banned_map = serializers.SerializerMethodField(method_name="get_banned_map") next_ban_team = serializers.SerializerMethodField(method_name="get_next_ban_team") maps_left = serializers.ListField(child=serializers.CharField()) map_bans_count = serializers.IntegerField() + + def get_match(self, obj) -> MatchSerializer: + return MatchSerializer(self.context["match"], context={"request": self.context["request"]}).data + def get_banned_map(self, obj) -> MapSerializer: return MapSerializer(self.context["banned_map"]).data @@ -232,5 +265,29 @@ def get_next_pick_team(self, obj) -> TeamSerializer: return TeamSerializer(self.context["next_pick_team"]).data -class MatchPlayerJoin(InteractionUserSerializer): - pass +class MatchPlayerJoin(serializers.Serializer): + interaction_user_id = serializers.CharField(required=True, + validators=[ValidDiscordUser(), DiscordUserCanJoinMatch()]) + team = serializers.CharField(required=False, validators=[TeamCanBeJoined()]) + + def validate_team(self, value): + if value not in ["team1", "team2"]: + raise serializers.ValidationError("Team must be either 'team1' or 'team2'") + return value + + +class MatchPlayerLeave(serializers.Serializer): + interaction_user_id = serializers.CharField(required=True, + validators=[ValidDiscordUser(), DiscordUserCanLeaveMatch(), + DiscordUserIsInMatch()]) + + +class MatchSelectCaptain(serializers.Serializer): + team = serializers.CharField(required=True) + interaction_user_id = serializers.CharField(required=True, validators=[ValidDiscordUser(), + DiscordUserIsInMatch(), ]) + + def validate_team(self, value): + if value not in ["team1", "team2"]: + raise serializers.ValidationError("Team must be either 'team1' or 'team2'") + return value diff --git a/src/matches/tests/conftest.py b/src/matches/tests/conftest.py index f32d0ee..a46fb68 100644 --- a/src/matches/tests/conftest.py +++ b/src/matches/tests/conftest.py @@ -2,10 +2,11 @@ from django.test import RequestFactory from rest_framework.reverse import reverse_lazy -from matches.models import Map, MatchType, Match +from matches.models import Map, MatchType, Match, MapPool, MatchConfig, GameMode from players.tests.conftest import player, discord_user_data, steam_user_data, players, teams, teams_with_players, default_author from servers.tests.conftest import server, server_data from guilds.tests.conftest import guild, guild_data + @pytest.fixture def map_data(): return { @@ -14,7 +15,6 @@ def map_data(): } -@pytest.mark.django_db @pytest.fixture(autouse=True) def default_maps(): maps_dict = [ @@ -49,51 +49,121 @@ def default_maps(): ] maps_obj = [Map.objects.get_or_create(**map) for map in maps_dict] +@pytest.fixture(autouse=True) +def default_map_pools(): + active_duty_map_pool = MapPool.objects.create(name="Competive Active Duty") + active_duty_map_pool.maps.set(Map.objects.all()) + active_duty_map_pool.save() + + wingman_map_pool = MapPool.objects.create(name="Wingman Active Duty") + wingman_map_pool.maps.set(Map.objects.filter(tag__in=["de_nuke", "de_inferno", "de_overpass", "de_vertigo"])) + wingman_map_pool.save() + + +configs = [ + "5v5_bo1_shuffle_teams_official", + "5v5_bo1_official", + "5v5_b03_shuffle_teams_official", + "5v5_b03_official", + "wingman_b01_official", + "wingman_b03_official" +] + +@pytest.fixture(autouse=True) +def default_match_configs(): + active_duty_map_pool = MapPool.objects.get(name="Competive Active Duty") + wingman_map_pool = MapPool.objects.get(name="Wingman Active Duty") + + MatchConfig.objects.create( + name=configs[0], + game_mode=GameMode.COMPETITIVE, + type=MatchType.BO1, + clinch_series=False, + max_players=10, + shuffle_teams=True, + map_pool=active_duty_map_pool, + ) + + MatchConfig.objects.create( + name=configs[1], + game_mode=GameMode.COMPETITIVE, + type=MatchType.BO1, + clinch_series=False, + max_players=10, + map_pool=active_duty_map_pool, + ) + + MatchConfig.objects.create( + name=configs[2], + game_mode=GameMode.COMPETITIVE, + type=MatchType.BO3, + clinch_series=False, + max_players=10, + shuffle_teams=True, + map_pool=active_duty_map_pool, + ) + + MatchConfig.objects.create( + name=configs[3], + game_mode=GameMode.COMPETITIVE, + type=MatchType.BO3, + clinch_series=False, + max_players=10, + map_pool=active_duty_map_pool, + ) + + MatchConfig.objects.create( + name=configs[4], + game_mode=GameMode.WINGMAN, + type=MatchType.BO1, + clinch_series=False, + max_players=4, + map_pool=wingman_map_pool, + ) + + MatchConfig.objects.create( + name=configs[5], + game_mode=GameMode.WINGMAN, + type=MatchType.BO3, + clinch_series=False, + max_players=4, + map_pool=wingman_map_pool, + ) @pytest.fixture -def match_data(players, default_author, guild): +def match_data(default_author, guild): return { - "discord_users_ids": [player.discord_user.user_id for player in players], + "config_name": "5v5_bo1_official", "author_id": default_author.player.discord_user.user_id, - "guild_id": guild.id, - "match_type": MatchType.BO1, - "players_per_team": 5, - "clinch_series": False, - "map_sides": ["knife", "knife", "knife"], + "guild_id": guild.guild_id, } @pytest.fixture -def match(teams_with_players, players, default_author, guild): +def match(default_author, guild): factory = RequestFactory() # Create a request request = factory.get('/') - team1, team2 = teams_with_players - new_match: Match = Match.objects.create_match( - team1=team1, - team2=team2, + new_match = Match.objects.create( + config=MatchConfig.objects.get(name="5v5_bo1_official"), author=default_author.player.discord_user, - map_sides=["knife", "knife", "knife"], guild=guild, ) new_match.create_webhook_cvars(str(reverse_lazy("match-webhook", args=[new_match.pk], request=request))) return new_match @pytest.fixture -def match_with_server(server, teams_with_players, default_author, guild): - team1, team2 = teams_with_players +def match_with_server(server, default_author, guild): factory = RequestFactory() # Create a request request2 = factory.get('/') - new_match = Match.objects.create_match( - team1=team1, - team2=team2, + new_match = Match.objects.create( + config=MatchConfig.objects.get(name="5v5_bo1_official"), author=default_author.player.discord_user, - map_sides=["knife", "knife", "knife"], - server=server, guild=guild, + server=server ) new_match.create_webhook_cvars(str(reverse_lazy("match-webhook", args=[new_match.pk], request=request2))) return new_match \ No newline at end of file diff --git a/src/matches/tests/test_matches_models.py b/src/matches/tests/test_matches_models.py index 479f44d..10342c1 100644 --- a/src/matches/tests/test_matches_models.py +++ b/src/matches/tests/test_matches_models.py @@ -4,73 +4,134 @@ from rest_framework.authtoken.models import Token from rest_framework.reverse import reverse_lazy -from matches.models import Match, MatchStatus, MatchType +from matches.models import Match, MatchStatus, MatchType, MatchConfig, GameMode +from matches.tests.conftest import configs from servers.tests.conftest import server from players.tests.conftest import teams_with_players, default_author @pytest.mark.django_db @pytest.mark.parametrize("with_server", [True, False]) -@pytest.mark.parametrize("match_type", [MatchType.BO1, MatchType.BO3]) -@pytest.mark.parametrize("clinch_series", [True, False]) -def test_match_model(teams_with_players, default_author, with_server, match_type, server, clinch_series): - team1, team2 = teams_with_players +@pytest.mark.parametrize("config", configs) +def test_match_model(with_server, server, config, guild, default_author): server = server if with_server else None factory = RequestFactory() # Create a request request = factory.get('/') - new_match: Match = Match.objects.create_match( - team1=team1, - team2=team2, + match_config = MatchConfig.objects.get(name=config) + new_match: Match = Match.objects.create( + config=match_config, author=default_author.player.discord_user, - type=match_type, - clinch_series=clinch_series, - map_sides=["knife", "knife", "knife"], - server=server, + guild=guild, + server=server ) new_match.create_webhook_cvars(str(reverse_lazy("match-webhook", args=[new_match.pk], request=request))) assert new_match.status == MatchStatus.CREATED - assert new_match.type == match_type - assert new_match.team1 == team1 - assert new_match.team2 == team2 + + assert new_match.config.name == match_config.name + assert new_match.config.game_mode == match_config.game_mode + assert new_match.config.type == match_config.type + assert new_match.config.clinch_series == match_config.clinch_series + assert new_match.config.max_players == match_config.max_players + assert new_match.config.map_pool == match_config.map_pool + assert new_match.config.map_sides == match_config.map_sides + + assert new_match.team1 is not None + assert new_match.team2 is not None + assert new_match.team1.name == "Team 1" + assert new_match.team2.name == "Team 2" + assert new_match.winner_team is None + assert new_match.map_bans.count() == 0 + assert new_match.map_picks.count() == 0 + assert new_match.last_map_ban is None + assert new_match.last_map_pick is None + assert new_match.maplist is None + assert new_match.server == server + assert new_match.guild == guild assert new_match.author == default_author.player.discord_user - assert new_match.players_per_team == 5 - assert new_match.clinch_series is clinch_series - assert new_match.map_sides == ["knife", "knife", "knife"] - assert new_match.maplist == new_match.maplist + assert new_match.message_id is None + assert new_match.embed is not None team1_players_dict = new_match.get_team1_players_dict() team2_players_dict = new_match.get_team2_players_dict() - assert team1_players_dict["name"] == team1.name - assert team2_players_dict["name"] == team2.name + assert team1_players_dict["name"] == "Team 1" + assert team2_players_dict["name"] == "Team 2" - assert len(team1_players_dict["players"]) == 5 - assert len(team2_players_dict["players"]) == 5 + assert len(team1_players_dict["players"]) == 1 + assert len(team2_players_dict["players"]) == 0 - match_config = new_match.get_config() - - assert match_config["matchid"] == new_match.pk - assert match_config["team1"] == team1_players_dict - assert match_config["team2"] == team2_players_dict - assert match_config["num_maps"] == 1 if match_type == MatchType.BO1 else 3 - assert match_config["maplist"] == new_match.maplist - assert match_config["map_sides"] == ["knife", "knife", "knife"] - assert match_config["clinch_series"] is clinch_series - assert match_config["players_per_team"] == 5 - assert match_config["cvars"] == new_match.cvars - assert match_config["cvars"]["matchzy_remote_log_url"] == reverse_lazy("match-webhook", args=[new_match.pk], request=request) - assert match_config["cvars"]["matchzy_remote_log_header_key"] == new_match.api_key_header - assert match_config["cvars"]["matchzy_remote_log_header_value"] == f"Bearer {new_match.get_author_token()}" + assert new_match.api_key_header == "Authorization" + assert new_match.load_match_command_name == "matchzy_loadmatch_url" + assert new_match.get_author_token() == Token.objects.get(user=default_author).key + assert new_match.get_connect_command() == "" if with_server is False else server.get_connect_string() - connect_command = new_match.get_connect_command() - assert connect_command == "" if with_server is False else server.get_connect_string() + matchzy_config = new_match.get_matchzy_config() + assert matchzy_config["matchid"] == new_match.pk + assert matchzy_config["team1"] == team1_players_dict + assert matchzy_config["team2"] == team2_players_dict + assert matchzy_config["num_maps"] == 1 if match_config.type == MatchType.BO1 else 3 + assert matchzy_config["maplist"] == new_match.maplist + assert matchzy_config["map_sides"] == match_config.map_sides + assert matchzy_config["clinch_series"] == match_config.clinch_series + assert matchzy_config["players_per_team"] == 1 + assert matchzy_config["cvars"] == new_match.cvars + assert matchzy_config["cvars"]["matchzy_remote_log_url"] == reverse_lazy("match-webhook", args=[new_match.pk], request=request) + assert matchzy_config["cvars"]["matchzy_remote_log_header_key"] == new_match.api_key_header + assert matchzy_config["cvars"]["matchzy_remote_log_header_value"] == f"Bearer {new_match.get_author_token()}" - author_token = new_match.get_author_token() - assert author_token == Token.objects.get(user=default_author).key + if match_config.game_mode == GameMode.WINGMAN: + assert matchzy_config["wingman"] is True - assert new_match.load_match_command_name == "matchzy_loadmatch_url" assert new_match.api_key_header == "Authorization" + assert new_match.load_match_command_name == "matchzy_loadmatch_url" +@pytest.mark.django_db +@pytest.mark.parametrize("config", configs) +def test_match_get_team1_players_dict(match, config): + match.config = MatchConfig.objects.get(name=config) + match.save() + team1_dict = match.get_team1_players_dict() + assert team1_dict["name"] == match.team1.name + assert len(team1_dict["players"]) == match.team1.players.count() + assert len(team1_dict["players"]) == 1 + +@pytest.mark.django_db +@pytest.mark.parametrize("config", configs) +def test_match_get_team2_players_dict(match, config): + match.config = MatchConfig.objects.get(name=config) + match.save() + team2_dict = match.get_team2_players_dict() + assert team2_dict["name"] == match.team2.name + assert len(team2_dict["players"]) == match.team2.players.count() + assert len(team2_dict["players"]) == 0 +@pytest.mark.django_db +@pytest.mark.parametrize("config", configs) +def test_match_get_matchzy_config(rf, match, config): + request = rf.get("/") + match.config = MatchConfig.objects.get(name=config) + match.save() + matchzy_config = match.get_matchzy_config() + assert matchzy_config["matchid"] == match.pk + assert matchzy_config["team1"] == match.get_team1_players_dict() + assert matchzy_config["team2"] == match.get_team2_players_dict() + assert matchzy_config["num_maps"] == 1 if match.config.type == MatchType.BO1 else 3 + assert matchzy_config["maplist"] == match.maplist + assert matchzy_config["map_sides"] == match.config.map_sides + assert matchzy_config["clinch_series"] == match.config.clinch_series + assert matchzy_config["players_per_team"] == 1 + assert matchzy_config["cvars"] == match.cvars + assert matchzy_config["cvars"]["matchzy_remote_log_url"] == reverse_lazy("match-webhook", args=[match.pk], request=request) + assert matchzy_config["cvars"]["matchzy_remote_log_header_key"] == match.api_key_header + assert matchzy_config["cvars"]["matchzy_remote_log_header_value"] == f"Bearer {match.get_author_token()}" + + if match.config.game_mode == GameMode.WINGMAN: + assert matchzy_config["wingman"] is True +@pytest.mark.django_db +@pytest.mark.parametrize("config", configs) +def test_get_connect_command(match, server, config): + match.server = server + match.save() + assert match.get_connect_command() == server.get_connect_string() diff --git a/src/matches/utils.py b/src/matches/utils.py index bfd27c3..cac4999 100644 --- a/src/matches/utils.py +++ b/src/matches/utils.py @@ -8,6 +8,7 @@ import redis from rest_framework.authtoken.models import Token from rest_framework.reverse import reverse_lazy +from typing_extensions import deprecated from guilds.models import Guild from matches.models import ( @@ -16,7 +17,7 @@ MapBan, MapPick, MatchStatus, - MatchType, + MatchType, MatchConfig, ) from matches.serializers import ( CreateMatchSerializer, @@ -30,7 +31,7 @@ MatchPickMapSerializer, MatchPlayerJoin, MatchSerializer, MatchBanMapResultSerializer, MatchPickMapResultSerializer, InteractionUserSerializer, - MapSerializer, + MapSerializer, MatchPlayerLeave, MatchSelectCaptain, ) from players.models import DiscordUser, Player, Team from players.serializers import TeamSerializer @@ -87,7 +88,7 @@ def check_server_is_available_for_match(server: Server) -> bool: return True -def create_match(request: Request) -> Response: +def create_match_deprecated(request: Request) -> Response: """ Create a new match. @@ -186,6 +187,42 @@ def create_match(request: Request) -> Response: return Response(new_match_serializer.data, status=201) +def create_match(request: Request) -> Response: + """ + Create a new match. + + Args: + ----- + request (Request): Request object. + + Returns: + -------- + Response: Response object. + """ + serializer = CreateMatchSerializer(data=request.data, context={"request": request}) + serializer.is_valid(raise_exception=True) + author_id = serializer.validated_data.get("author_id") + server_id = serializer.validated_data.get("server_id") + guild_id = serializer.validated_data.get("guild_id") + config_name = serializer.validated_data.get("config_name") + author = DiscordUser.objects.get(user_id=author_id) + guild = Guild.objects.get(guild_id=guild_id) + config = MatchConfig.objects.get(name=config_name) + + server = None + if server_id: + server = Server.objects.get(pk=server_id) + new_match: Match = Match.objects.create( + config=config, + author=author, + guild=guild, + server=server + ) + new_match.create_webhook_cvars(webhook_url=str(reverse_lazy("match-webhook", args=[new_match.pk], request=request))) + new_match_serializer = MatchSerializer(new_match, context={"request": request}) + return Response(new_match_serializer.data, status=201) + + def load_match(pk: int, request) -> Response: """ Load a match into the server. @@ -218,7 +255,7 @@ def load_match(pk: int, request) -> Response: return Response(match_serializer.data, status=200) -def ban_map(request: Request, pk: int) -> Response: +def ban_map_deprecated(request: Request, pk: int) -> Response: """ Ban a map from the match. @@ -306,16 +343,72 @@ def ban_map(request: Request, pk: int) -> Response: for map_pick in match.map_picks.all(): maps_left_without_picked_map.remove(map_pick.map.tag) ban_result_serializer = MatchBanMapResultSerializer( - context={"banned_map": map, "next_ban_team": match.team1 if match.team2 == user_team else match.team2,}, + context={"banned_map": map, "next_ban_team": match.team1 if match.team2 == user_team else match.team2, }, data={ "maps_left": maps_left_without_picked_map, "map_bans_count": match.map_bans.count(), } - ) + ) ban_result_serializer.is_valid(raise_exception=True) return Response(ban_result_serializer.data, status=200) +def ban_map(request: Request, pk: int) -> Response["MatchBanMapResultSerializer"]: + """ + Ban a map from the match. + + Args: + ----- + request (Request): Request object. + pk (int): Match ID. + + Returns: + -------- + Response: Response object. + """ + match: Match = get_object_or_404(Match, pk=pk) + match_map_ban_serializer = MatchBanMapSerializer(data=request.data, context={"match": match}) + match_map_ban_serializer.is_valid(raise_exception=True) + interaction_user_id = match_map_ban_serializer.validated_data.get("interaction_user_id") + map_tag = match_map_ban_serializer.validated_data.get("map_tag") + + player = Player.objects.get(discord_user__user_id=interaction_user_id) + map = Map.objects.get(tag=map_tag) + team = match.get_team_by_player(player) + if match.last_map_ban and match.last_map_ban.team == team: + return Response( + {"message": "You already banned a map. Wait for the other team to ban a map"}, + status=400, + ) + if match.map_bans.count() == 0 and team == match.team2: + return Response( + {"message": "Team 1 has to ban first"}, + status=400, + ) + + if match.config.type == MatchType.BO1: + # 6 bans + map_bans_count = match.map_bans.count() + # 7 maps + map_pool_count = match.config.map_pool.maps.count() - 1 + if map_bans_count == map_pool_count: + return Response({"message": "Only one map left. You can't ban more maps"}, status=400) + + match.ban_map(team, map) + maps_left = match.get_maps_left() + next_ban_team = match.get_next_ban_team() + map_bans_count = match.map_bans.count() + map_ban_result_serializer = MatchBanMapResultSerializer( + context={"banned_map": map, "next_ban_team": next_ban_team, "match": match, "request": request}, + data={ + "maps_left": maps_left, + "map_bans_count": map_bans_count, + } + ) + map_ban_result_serializer.is_valid(raise_exception=True) + return Response(map_ban_result_serializer.data, status=200) + + def pick_map(request: Request, pk: int) -> Response["MatchPickMapResultSerializer"]: """ Pick a map for the match. @@ -404,7 +497,7 @@ def pick_map(request: Request, pk: int) -> Response["MatchPickMapResultSerialize maps_left_without_picked_map.remove(map_pick.map.tag) map_pick_result_serializer = MatchPickMapResultSerializer( - context={"picked_map": map, "next_pick_team": match.team1 if match.team2 == user_team else match.team2,}, + context={"picked_map": map, "next_pick_team": match.team1 if match.team2 == user_team else match.team2, }, data={ "maps_left": maps_left_without_picked_map, "map_picks_count": match.map_picks.count(), @@ -531,9 +624,44 @@ def process_webhook(request: Request, pk) -> Response: return Response({"event": redis_event, "data": data}, status=200) +def recreate_match(request, pk: int) -> Response: + """ + Create a new match with the same teams. + + Args: + ----- + pk (int): Match ID. + + Returns: + -------- + Response: Response object. + """ + match: Match = get_object_or_404(Match, pk=pk) + serializer = InteractionUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + interaction_user_id = serializer.validated_data.get("interaction_user_id") + author = get_object_or_404(DiscordUser, user_id=interaction_user_id) + if author.pk != match.author.pk: + return Response( + {"message": "Only the author of the match can recreate the match"}, + status=400, + ) + new_match = Match.objects.create_match( + type=match.type, + team1=match.team1, + team2=match.team2, + author=match.author, + guild=match.guild, + server=match.server, + ) + new_match.create_webhook_cvars(str(reverse_lazy("match-webhook", args=[new_match.pk], request=request))) + new_match_serializer = MatchSerializer(new_match, context={"request": request}) + return Response(new_match_serializer.data, status=201) + + def join_match(request: Request, pk: int) -> Response: """ - Join a match. + Join a player to a team. Args: ----- @@ -545,37 +673,38 @@ def join_match(request: Request, pk: int) -> Response: Response: Response object. """ match: Match = get_object_or_404(Match, pk=pk) - match_player_join_serializer = MatchPlayerJoin(data=request.data) - if not match_player_join_serializer.is_valid(): - return Response(match_player_join_serializer.errors, status=400) + serializer = MatchPlayerJoin(data=request.data, context={"match": match}) + serializer.is_valid(raise_exception=True) + interaction_user_id = serializer.validated_data.get("interaction_user_id") + team = serializer.validated_data.get("team") - discord_user_id = match_player_join_serializer.validated_data.get("interaction_user_id") - player = get_object_or_404(Player, discord_user__user_id=discord_user_id) - if match.team1.players.filter(pk=player.id).exists(): + discord_user = DiscordUser.objects.get(user_id=interaction_user_id) + + player = Player.objects.get(discord_user=discord_user) + if team == "team1" and match.team1.players.filter(discord_user=discord_user).exists(): return Response( - {"message": f"Player {player.steam_user.username} is already in team 1"}, + {"message": f"DiscordUser @<{interaction_user_id}> is already in team1"}, status=400, ) - if match.team2.players.filter(pk=player.id).exists(): + if team == "team2" and match.team2.players.filter(discord_user=discord_user).exists(): return Response( - {"message": f"Player {player.steam_user.username} is already in team 2"}, + {"message": f"DiscordUser @<{interaction_user_id}> is already in team2"}, status=400, ) - if match.team1.players.count() < match.team2.players.count(): - match.team1.players.add(player) - else: - match.team2.players.add(player) - match.save() + match.add_player_to_match(player, team) + if match.team1.players.count() + match.team2.players.count() == match.config.max_players: + match.start_match() match_serializer = MatchSerializer(match, context={"request": request}) return Response(match_serializer.data, status=200) -def recreate_match(request, pk: int) -> Response: +def leave_match(request: Request, pk: int) -> Response: """ - Create a new match with the same teams. + Leave a match. Args: ----- + request (Request): Request object. pk (int): Match ID. Returns: @@ -583,23 +712,59 @@ def recreate_match(request, pk: int) -> Response: Response: Response object. """ match: Match = get_object_or_404(Match, pk=pk) - serializer = InteractionUserSerializer(data=request.data) + serializer = MatchPlayerLeave(data=request.data, context={"match": match}) serializer.is_valid(raise_exception=True) interaction_user_id = serializer.validated_data.get("interaction_user_id") - author = get_object_or_404(DiscordUser, user_id=interaction_user_id) - if author.pk != match.author.pk: - return Response( - {"message": "Only the author of the match can recreate the match"}, - status=400, - ) - new_match = Match.objects.create_match( - type=match.type, - team1=match.team1, - team2=match.team2, - author=match.author, - guild=match.guild, - server=match.server, - ) - new_match.create_webhook_cvars(str(reverse_lazy("match-webhook", args=[new_match.pk], request=request))) - new_match_serializer = MatchSerializer(new_match, context={"request": request}) - return Response(new_match_serializer.data, status=201) + discord_user = DiscordUser.objects.get(user_id=interaction_user_id) + player = Player.objects.get(discord_user=discord_user) + match.remove_player_from_match(player) + match_serializer = MatchSerializer(match, context={"request": request}) + return Response(match_serializer.data, status=200) + + +def select_captain(request: Request, pk: int) -> Response: + """ + Select a capitan for a team. + + Args: + ----- + request (Request): Request object. + pk (int): Match ID. + + Returns: + -------- + Response: Response object. + """ + match: Match = get_object_or_404(Match, pk=pk) + + serializer = MatchSelectCaptain(data=request.data, context={"match": match}) + serializer.is_valid(raise_exception=True) + + interaction_user_id = serializer.validated_data.get("interaction_user_id") + team = serializer.validated_data.get("team") + player = Player.objects.get(discord_user__user_id=interaction_user_id) + if team == "team1": + if not match.team1.players.filter(discord_user__user_id=interaction_user_id).exists(): + return Response( + {"message": f"DiscordUser @<{interaction_user_id}> is not in team1"}, + status=400, + ) + if match.team1.leader: + return Response( + {"message": f"Team1 already has a captain"}, + status=400, + ) + if team == "team2": + if not match.team2.players.filter(discord_user__user_id=interaction_user_id).exists(): + return Response( + {"message": f"DiscordUser @<{interaction_user_id}> is not in team2"}, + status=400, + ) + if match.team2.leader: + return Response( + {"message": f"Team2 already has a captain"}, + status=400, + ) + match.set_team_captain(player, team) + match_serializer = MatchSerializer(match, context={"request": request}) + return Response(match_serializer.data, status=200) diff --git a/src/matches/validators.py b/src/matches/validators.py new file mode 100644 index 0000000..dde378c --- /dev/null +++ b/src/matches/validators.py @@ -0,0 +1,105 @@ +from rest_framework import serializers + +from matches.models import MatchStatus, Map, MatchType +from players.models import DiscordUser + + +class ValidDiscordUser(object): + + def __call__(self, value): + if not DiscordUser.objects.filter(user_id=value).exists(): + raise serializers.ValidationError(f"DiscordUser @<{value}> does not exist") + + +class DiscordUserIsInMatch(object): + requires_context = True + + def __call__(self, value, serializer_field): + match = serializer_field.context.get("match") + if not match.team1.players.filter(discord_user__user_id=value).exists() and not match.team2.players.filter( + discord_user__user_id=value).exists(): + raise serializers.ValidationError(f"DiscordUser @<{value}> is not in a match") + + +class DiscordUserCanJoinMatch(object): + requires_context = True + + def __call__(self, value, serializer_field): + match = serializer_field.context.get("match") + if match.status != MatchStatus.CREATED: + raise serializers.ValidationError("Match is not in CREATED status") + + # Allow author to join match + if match.author.user_id == value: + return + # if match.team1.players.filter(discord_user__user_id=value).exists() or match.team2.players.filter( + # discord_user__user_id=value).exists(): + # raise serializers.ValidationError(f"DiscordUser @<{value}> is already in a match") + + if (match.team1.players.count() + match.team2.players.count()) >= match.config.max_players: + raise serializers.ValidationError("Match is full") + + if match.author.user_id == value and match.config.shuffle_teams: + raise serializers.ValidationError(f"DiscordUser @<{value}> is author of the match and cannot join") + + +class TeamCanBeJoined(object): + requires_context = True + + def __call__(self, value, serializer_field): + match = serializer_field.context.get("match") + + if value == "team1" and match.team1.players.count() >= match.config.max_players // 2: + raise serializers.ValidationError("Team1 is full") + if value == "team2" and match.team2.players.count() >= match.config.max_players // 2: + raise serializers.ValidationError("Team2 is full") + + +class DiscordUserCanLeaveMatch(object): + requires_context = True + + def __call__(self, value, serializer_field): + match = serializer_field.context.get("match") + if match.status != MatchStatus.CREATED: + raise serializers.ValidationError("Match is not in CREATED status") + + if match.author.user_id == value: + raise serializers.ValidationError(f"DiscordUser @<{value}> is author of the match and cannot leave") + + +class ValidMap(object): + requires_context = True + def __call__(self, value, serializer_field): + match = serializer_field.context.get("match") + if not Map.objects.filter(tag=value).exists(): + raise serializers.ValidationError(f"Map {value} does not exist") + if not match.config.map_pool.maps.filter(tag=value).exists(): + raise serializers.ValidationError(f"Map {value} is not in the map pool") + + +class MapCanBeBanned(object): + requires_context = True + + def __call__(self, value, serializer_field): + match = serializer_field.context.get("match") + if match.status != MatchStatus.MAP_VETO: + raise serializers.ValidationError("Match is not in MAP_VETO status") + + if match.map_bans.filter(map__tag=value).exists(): + raise serializers.ValidationError(f"Map {value} is already banned") + + if match.map_picks.filter(map__tag=value).exists(): + raise serializers.ValidationError(f"Map {value} is already picked") + + + + +class DiscordUserIsCaptain(object): + requires_context = True + + def __call__(self, value, serializer_field): + match = serializer_field.context.get("match") + if match.team1.leader.user_id != value and match.team2.leader.user_id != value: + raise serializers.ValidationError(f"DiscordUser @<{value}> is not a captain") + + diff --git a/src/matches/views.py b/src/matches/views.py index 306c9c1..670b708 100644 --- a/src/matches/views.py +++ b/src/matches/views.py @@ -18,7 +18,8 @@ MatchConfigSerializer, MatchMapSelectedSerializer, MatchSerializer, CreateMatchSerializer, MatchBanMapSerializer, MatchPickMapSerializer, MatchBanMapResultSerializer, - MatchPickMapResultSerializer, InteractionUserSerializer, MatchUpdateSerializer, + MatchPickMapResultSerializer, InteractionUserSerializer, MatchUpdateSerializer, MatchPlayerJoin, MatchPlayerLeave, + MatchSelectCaptain, ) from matches.utils import ( ban_map, @@ -28,7 +29,7 @@ pick_map, process_webhook, recreate_match, - shuffle_teams, + shuffle_teams, leave_match, select_captain, ) from players.models import Team, DiscordUser from servers.models import Server @@ -156,13 +157,30 @@ def picks(self, request, pk): return Response(serializer.data) @extend_schema( - request=InteractionUserSerializer, + request=MatchPlayerJoin, responses={200: MatchSerializer} ) @action(detail=True, methods=["POST"]) def join(self, request, pk): return join_match(request, pk) + @extend_schema( + request=MatchPlayerLeave, + responses={200: MatchSerializer} + ) + @action(detail=True, methods=["POST"]) + def leave(self, request, pk): + return leave_match(request, pk) + + + @extend_schema( + request=MatchSelectCaptain, + responses={200: MatchSerializer} + ) + @action(detail=True, methods=["POST"]) + def captain(self, request, pk): + return select_captain(request, pk) + @extend_schema( responses={200: MatchConfigSerializer} ) diff --git a/src/players/migrations/0006_alter_team_leader.py b/src/players/migrations/0006_alter_team_leader.py new file mode 100644 index 0000000..b616c37 --- /dev/null +++ b/src/players/migrations/0006_alter_team_leader.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.6 on 2024-05-10 15:15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('players', '0005_alter_player_steam_user'), + ] + + operations = [ + migrations.AlterField( + model_name='team', + name='leader', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='team_leader', to='players.player'), + ), + ] diff --git a/src/players/models.py b/src/players/models.py index 78930ee..d84e196 100644 --- a/src/players/models.py +++ b/src/players/models.py @@ -50,6 +50,7 @@ class Team(models.Model): on_delete=models.CASCADE, related_name="team_leader", null=True, + blank=True ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/src/servers/admin.py b/src/servers/admin.py index 6cfd0da..02edaf8 100644 --- a/src/servers/admin.py +++ b/src/servers/admin.py @@ -4,4 +4,10 @@ # Register your models here. -admin.site.register(Server) \ No newline at end of file +class AdminServer(admin.ModelAdmin): + readonly_fields = ('id', 'created_at', 'updated_at') + + + + +admin.site.register(Server, AdminServer) \ No newline at end of file