From bfe49daa4c9d40155e7ade8909ea4bc59686da33 Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 02:52:22 +0300 Subject: [PATCH 1/2] rewrite migrations; fix comptitions tests --- .../competition/migrations/0001_initial.py | 10 ++-- .../0002_alter_competition_image_url.py | 19 -------- services/backend/apps/competition/tests.py | 46 ++++++------------- .../apps/review/migrations/0001_initial.py | 28 ++++++----- .../apps/review/migrations/0002_initial.py | 27 ----------- services/backend/apps/task/admin.py | 8 +++- .../apps/task/migrations/0001_initial.py | 5 +- .../0002_alter_competitiontask_options.py | 17 ------- .../apps/team/migrations/0001_initial.py | 16 ++++++- services/backend/apps/team/models.py | 12 +++++ .../apps/user/migrations/0001_initial.py | 8 ++-- ...user_email_alter_user_password_and_more.py | 28 ----------- 12 files changed, 76 insertions(+), 148 deletions(-) delete mode 100644 services/backend/apps/competition/migrations/0002_alter_competition_image_url.py delete mode 100644 services/backend/apps/review/migrations/0002_initial.py delete mode 100644 services/backend/apps/task/migrations/0002_alter_competitiontask_options.py delete mode 100644 services/backend/apps/user/migrations/0002_alter_user_email_alter_user_password_and_more.py diff --git a/services/backend/apps/competition/migrations/0001_initial.py b/services/backend/apps/competition/migrations/0001_initial.py index 944d20b..d2222af 100644 --- a/services/backend/apps/competition/migrations/0001_initial.py +++ b/services/backend/apps/competition/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-01 20:35 +# Generated by Django 5.1.6 on 2025-03-01 23:48 import apps.competition.models import datetime @@ -12,7 +12,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('user', '0002_alter_user_email_alter_user_password_and_more'), + ('user', '0001_initial'), ] operations = [ @@ -22,11 +22,11 @@ class Migration(migrations.Migration): ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('title', models.CharField(max_length=100, verbose_name='название')), ('description', models.TextField(verbose_name='описание')), - ('image_url', models.FileField(blank=True, null=True, upload_to=apps.competition.models.Competition.image_url_upload_to, verbose_name='изображение соревнования')), + ('image_url', models.ImageField(blank=True, null=True, upload_to=apps.competition.models.Competition.image_url_upload_to, verbose_name='изображение соревнования')), ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия')), ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия')), - ('type', models.CharField(choices=[('solo', 'Индивидуальный')], max_length=10, verbose_name='тип участия')), - ('participation_type', models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип соревнования')), + ('type', models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип участия')), + ('participation_type', models.CharField(choices=[('solo', 'Индивидуальный')], max_length=11, verbose_name='тип соревнования')), ('participants', models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user')), ], options={ diff --git a/services/backend/apps/competition/migrations/0002_alter_competition_image_url.py b/services/backend/apps/competition/migrations/0002_alter_competition_image_url.py deleted file mode 100644 index 6298789..0000000 --- a/services/backend/apps/competition/migrations/0002_alter_competition_image_url.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 22:16 - -import apps.competition.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('competition', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='competition', - name='image_url', - field=models.ImageField(blank=True, null=True, upload_to=apps.competition.models.Competition.image_url_upload_to, verbose_name='изображение соревнования'), - ), - ] diff --git a/services/backend/apps/competition/tests.py b/services/backend/apps/competition/tests.py index db60d92..3884d70 100644 --- a/services/backend/apps/competition/tests.py +++ b/services/backend/apps/competition/tests.py @@ -21,8 +21,8 @@ class CompetitionEndpointTests(TestCase): self.competition = Competition.objects.create( title="AI Challenge", description="Machine Learning Competition", - type="solo", - participation_type="edu", + type="edu", + participation_type="solo", ) resp = self.client.post( @@ -34,13 +34,10 @@ class CompetitionEndpointTests(TestCase): self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"} - # --- Helper methods --- def get_url(self, competition_id): return f"/api/v1/competition/{competition_id}" - # --- Test Cases --- def test_get_competition_success(self): - """Authenticated user gets competition details (200 OK)""" response = self.client.get( self.get_url(self.competition.id), **self.valid_headers ) @@ -51,7 +48,7 @@ class CompetitionEndpointTests(TestCase): # Validate required fields self.assertEqual(data["id"], str(self.competition.id)) self.assertEqual(data["title"], "AI Challenge") - self.assertEqual(data["type"], "solo") + self.assertEqual(data["type"], "edu") # Validate optional null fields self.assertIsNone(data["image_url"]) @@ -59,20 +56,17 @@ class CompetitionEndpointTests(TestCase): self.assertIsNone(data["end_date"]) def test_invalid_uuid_format(self): - """Invalid UUID format returns 400 Bad Request""" response = self.client.get( self.get_url("invalid-id"), **self.valid_headers ) self.assertEqual(response.status_code, 400) def test_unauthenticated_access(self): - """Missing auth token returns 401 Unauthorized""" response = self.client.get(self.get_url(self.competition.id)) self.assertEqual(response.status_code, 401) self.assertEqual(response.json()["detail"], "Unauthorized") def test_nonexistent_competition(self): - """Valid UUID but missing competition returns 404""" new_uuid = uuid.uuid4() response = self.client.get( self.get_url(new_uuid), **self.valid_headers @@ -81,7 +75,6 @@ class CompetitionEndpointTests(TestCase): self.assertEqual(response.json()["detail"], "Not Found") def test_invalid_auth_token(self): - """Invalid token returns 401 Unauthorized""" response = self.client.get( self.get_url(self.competition.id), HTTP_AUTHORIZATION="Bearer invalid_token", @@ -90,7 +83,6 @@ class CompetitionEndpointTests(TestCase): self.assertEqual(response.json()["detail"], "Unauthorized") def test_malformed_auth_header(self): - """Malformed Authorization header returns 401""" cases = [ ("InvalidScheme valid_token_123", 401), ("Bearer", 401), # Missing token @@ -128,11 +120,11 @@ class CompetitionsEndpointTests(TestCase): competition = Competition.objects.create( title=f"Competition {i}", description=f"Description {i}", - type=Competition.CompetitionType.SOLO, - participation_type=( - Competition.CompetitionParticipationType.EDU if i % 2 == 0 - else Competition.CompetitionParticipationType.COMPETITIVE + type=( + Competition.CompetitionType.EDU if i % 2 == 0 + else Competition.CompetitionType.COMPETITIVE ), + participation_type=Competition.CompetitionParticipationType.SOLO, start_date=(now + timedelta(days=i)).isoformat(), end_date=(now + timedelta(days=i + 7)).isoformat(), ) @@ -149,7 +141,6 @@ class CompetitionsEndpointTests(TestCase): return f"{base_url}?{params}" if params else base_url def test_get_participating_competitions(self): - """Test filtering competitions where user is participating""" response = self.client.get( self.get_url("is_participating=true"), **self.valid_headers @@ -164,17 +155,15 @@ class CompetitionsEndpointTests(TestCase): ) def test_competition_type_values(self): - """Test competition type choices are respected""" response = self.client.get( self.get_url("is_participating=true"), **self.valid_headers ) for item in response.json(): - self.assertEqual(item["type"], "solo") + self.assertEqual(item["type"], "competitive") def test_participation_type_values(self): - """Test participation type alternates between edu/competitive""" response = self.client.get( self.get_url("is_participating=false"), **self.valid_headers @@ -183,11 +172,10 @@ class CompetitionsEndpointTests(TestCase): types = [item["participation_type"] for item in response.json()] self.assertCountEqual( types, - ["competitive", "edu", "competitive"] + ["solo", "solo", "solo"] ) def test_datetime_formatting(self): - """Test start/end date ISO formatting""" response = self.client.get( self.get_url("is_participating=true"), **self.valid_headers @@ -206,7 +194,6 @@ class CompetitionsEndpointTests(TestCase): self.fail("Invalid end_date format") def test_competition_metadata(self): - """Test competition metadata fields""" response = self.client.get( self.get_url("is_participating=true"), **self.valid_headers @@ -215,27 +202,25 @@ class CompetitionsEndpointTests(TestCase): item = response.json()[0] self.assertEqual(item["title"], "Competition 1") self.assertEqual(item["description"], "Description 1") - self.assertEqual(item["type"], "solo") - self.assertEqual(item["participation_type"], "competitive") + self.assertEqual(item["type"], "competitive") + self.assertEqual(item["participation_type"], "solo") def test_verbose_name_consistency(self): - """Test model verbose names don't affect API schema""" response = self.client.get( self.get_url("is_participating=true"), **self.valid_headers ) item = response.json()[0] - self.assertNotIn("название", item) # Russian verbose name - self.assertIn("title", item) # Actual API field name + self.assertNotIn("название", item) + self.assertIn("title", item) def test_null_dates_handling(self): - """Test competitions with null dates""" competition = Competition.objects.create( title="No Dates Competition", description="Test competition", - type=Competition.CompetitionType.SOLO, - participation_type=Competition.CompetitionParticipationType.EDU + type=Competition.CompetitionType.EDU, + participation_type=Competition.CompetitionParticipationType.SOLO ) response = self.client.get( @@ -251,7 +236,6 @@ class CompetitionsEndpointTests(TestCase): self.assertIsNone(test_item["end_date"]) def test_participation_status_filtering(self): - """Test filtering by participation_type""" response = self.client.get( self.get_url("is_participating=false"), **self.valid_headers diff --git a/services/backend/apps/review/migrations/0001_initial.py b/services/backend/apps/review/migrations/0001_initial.py index 3a62d5a..d0d2eeb 100644 --- a/services/backend/apps/review/migrations/0001_initial.py +++ b/services/backend/apps/review/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 5.1.6 on 2025-03-01 20:35 +# Generated by Django 5.1.6 on 2025-03-01 23:48 +import django.db.models.deletion import uuid from django.db import migrations, models @@ -9,20 +10,10 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('task', '0001_initial'), ] operations = [ - migrations.CreateModel( - name='Review', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('evaluation', models.JSONField(blank=True, default=list, null=True)), - ('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], default='not_checked', max_length=11)), - ], - options={ - 'abstract': False, - }, - ), migrations.CreateModel( name='Reviewer', fields=[ @@ -35,4 +26,17 @@ class Migration(migrations.Migration): 'abstract': False, }, ), + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('evaluation', models.JSONField(blank=True, default=list, null=True)), + ('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], default='not_checked', max_length=11)), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='task.competitiontasksubmission')), + ('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer')), + ], + options={ + 'abstract': False, + }, + ), ] diff --git a/services/backend/apps/review/migrations/0002_initial.py b/services/backend/apps/review/migrations/0002_initial.py deleted file mode 100644 index 8b2836c..0000000 --- a/services/backend/apps/review/migrations/0002_initial.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 20:35 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('review', '0001_initial'), - ('task', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='review', - name='submission', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='task.competitiontasksubmission'), - ), - migrations.AddField( - model_name='review', - name='reviewer', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer'), - ), - ] diff --git a/services/backend/apps/task/admin.py b/services/backend/apps/task/admin.py index a466334..3749ff3 100644 --- a/services/backend/apps/task/admin.py +++ b/services/backend/apps/task/admin.py @@ -1,11 +1,17 @@ from django.contrib import admin -from apps.task.models import CompetitionTask +from apps.task.models import CompetitionTask, CompetitionTaskAttachment + + +class CompletionAttachmentInline(admin.StackedInline): + model = CompetitionTaskAttachment + extra = 0 @admin.register(CompetitionTask) class CompetitionTaskAdmin(admin.ModelAdmin): list_display = ("title", "type", "points") + inlines = [CompletionAttachmentInline] class CompetitionTaskInline(admin.StackedInline): diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py index 7f649f3..9060ff3 100644 --- a/services/backend/apps/task/migrations/0001_initial.py +++ b/services/backend/apps/task/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-01 20:35 +# Generated by Django 5.1.6 on 2025-03-01 23:48 import apps.task.models import django.db.models.deletion @@ -13,7 +13,7 @@ class Migration(migrations.Migration): dependencies = [ ('competition', '0001_initial'), - ('user', '0002_alter_user_email_alter_user_password_and_more'), + ('user', '0001_initial'), ] operations = [ @@ -32,6 +32,7 @@ class Migration(migrations.Migration): ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), ], options={ + 'verbose_name': 'задание', 'verbose_name_plural': 'задания', }, ), diff --git a/services/backend/apps/task/migrations/0002_alter_competitiontask_options.py b/services/backend/apps/task/migrations/0002_alter_competitiontask_options.py deleted file mode 100644 index ad73842..0000000 --- a/services/backend/apps/task/migrations/0002_alter_competitiontask_options.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 22:16 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('task', '0001_initial'), - ] - - operations = [ - migrations.AlterModelOptions( - name='competitiontask', - options={'verbose_name': 'задание', 'verbose_name_plural': 'задания'}, - ), - ] diff --git a/services/backend/apps/team/migrations/0001_initial.py b/services/backend/apps/team/migrations/0001_initial.py index 65f560b..70b13e9 100644 --- a/services/backend/apps/team/migrations/0001_initial.py +++ b/services/backend/apps/team/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-01 22:16 +# Generated by Django 5.1.6 on 2025-03-01 23:48 import django.db.models.deletion import uuid @@ -10,7 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('user', '0002_alter_user_email_alter_user_password_and_more'), + ('user', '0001_initial'), ] operations = [ @@ -27,4 +27,16 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'команды', }, ), + migrations.CreateModel( + name='TeamInvite', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('link', models.UUIDField(verbose_name='инвайт')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='team.team', verbose_name='команда')), + ], + options={ + 'verbose_name': 'приглашение', + 'verbose_name_plural': 'приглашения', + }, + ), ] diff --git a/services/backend/apps/team/models.py b/services/backend/apps/team/models.py index afee8c8..9cedc3c 100644 --- a/services/backend/apps/team/models.py +++ b/services/backend/apps/team/models.py @@ -1,3 +1,5 @@ +from uuid import uuid4 + from django.db import models from apps.core.models import BaseModel @@ -17,3 +19,13 @@ class Team(BaseModel): class Meta: verbose_name = "команда" verbose_name_plural = "команды" + + +class TeamInvite(BaseModel): + team = models.ForeignKey(Team, on_delete=models.CASCADE, + verbose_name="команда") + link = models.UUIDField(verbose_name="инвайт") + + class Meta: + verbose_name = "приглашение" + verbose_name_plural = "приглашения" diff --git a/services/backend/apps/user/migrations/0001_initial.py b/services/backend/apps/user/migrations/0001_initial.py index 6fb8be0..b4b83de 100644 --- a/services/backend/apps/user/migrations/0001_initial.py +++ b/services/backend/apps/user/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-01 08:47 +# Generated by Django 5.1.6 on 2025-03-01 23:48 import uuid from django.db import migrations, models @@ -16,9 +16,9 @@ class Migration(migrations.Migration): name='User', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')), - ('username', models.SlugField(unique=True, verbose_name='Юзернейм')), - ('password', models.TextField(verbose_name='Пароль')), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='почта')), + ('username', models.SlugField(unique=True, verbose_name='юзернейм')), + ('password', models.TextField(verbose_name='пароль')), ('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)), ], options={ diff --git a/services/backend/apps/user/migrations/0002_alter_user_email_alter_user_password_and_more.py b/services/backend/apps/user/migrations/0002_alter_user_email_alter_user_password_and_more.py deleted file mode 100644 index a733466..0000000 --- a/services/backend/apps/user/migrations/0002_alter_user_email_alter_user_password_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 14:46 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('user', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='email', - field=models.EmailField(max_length=254, unique=True, verbose_name='почта'), - ), - migrations.AlterField( - model_name='user', - name='password', - field=models.TextField(verbose_name='пароль'), - ), - migrations.AlterField( - model_name='user', - name='username', - field=models.SlugField(unique=True, verbose_name='юзернейм'), - ), - ] From 00a409317d838af764fc2f467cd7d64e7453e742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=A1=D1=83=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sun, 2 Mar 2025 02:57:41 +0300 Subject: [PATCH 2/2] feat: added state to competition response --- services/backend/api/v1/competition/schemas.py | 8 ++++++++ services/backend/apps/competition/models.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/services/backend/api/v1/competition/schemas.py b/services/backend/api/v1/competition/schemas.py index 079baab..c1252c2 100644 --- a/services/backend/api/v1/competition/schemas.py +++ b/services/backend/api/v1/competition/schemas.py @@ -8,6 +8,14 @@ from apps.competition.models import Competition, State class CompetitionOut(ModelSchema): id: UUID + state: Literal["not_started", "started", "finished"] + + @staticmethod + def resolve_state(self, context) -> Literal["not_started", "started", "finished"]: + if not (state := State.objects.filter(user=context.get("request").auth, competition=self).first()): + return "not_started" + return state.state + class Meta: model = Competition diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py index 9c0c0fc..9c37c79 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -60,5 +60,5 @@ class State(BaseModel): user = models.ForeignKey(User, on_delete=models.CASCADE) competition = models.ForeignKey(Competition, on_delete=models.CASCADE) - state = models.CharField(choices=StateChoices.choices, max_length=11) + state = models.CharField(choices=StateChoices.choices, max_length=11, default=StateChoices.NOT_STARTED.value) changed_at = models.DateTimeField(default=datetime.now)